From d64f9b947cd2ca6ef374688c2307647768fc5e26 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 5 Jan 2024 09:16:00 +0100 Subject: [PATCH] Integrate the `StoreKit2` purchase method (#10) * 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 * 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 * Write comments for public interfaces - Write comments for the `IAPProvider` - Write comments for the `ReceiptRefreshProvider` * Update `ci.yml` * Fix action errors - Update `Flare.xcscheme` - Update `test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable` test * Update the `Ruby` version from `2.7` to `3.1.4` * Implement `StoreTransaction` model The `StoreTransaction` model is a wrapper for both `SK1StoreTransaction` and `SK2StoreTransaction` * Create a `StoreTransaction` object The `StoreTransaction` object serves as a wrapper for both `SKTransaction` and `StoreKit.Transaction`. * Update `purchase(_:)` methods' parameters Replace purchasing a product by ID with passing a product object. * Integrate a host app for unit-tests A host app provides a means to test in-app purchases using the StoreKit2 API. * Refactor unit tests - Write unit tests for the new methods - Improve the readability of the existing test cases * Refactor the test target for supported platforms * Update `ci.yml` - Implement testing on different OS versions - Temporarily drop testing on watchOS * Remove `ObjectFactory` dependency * Update `project.yml` * Refactor test cases * Update `ci.yml` * Update `CHANGELOG.md` * Fix `Package.swift` & `Package@swift-5.7.swift` * Add `Package@swift-5.8.swift` * Implement integration tests - Create a new target that contains the integration tests - Implement three test plans: `AllTests`, `UnitTests`, `IntegrationTests` * Update configuration - Update the GitHub Actions CI script to enable testing on various platforms * Fix typos * Write a script for merging test results from various platforms * Implement the `finish(_:)` transaction method * Upload test coverate reports * Rename step --- .github/workflows/ci.yml | 214 ++++++++++++--- .github/workflows/danger.yml | 2 +- .gitignore | 1 + .swiftlint.yml | 2 + .../xcshareddata/xcschemes/Flare.xcscheme | 17 ++ .../xcschemes/FlareTests.xcscheme | 52 ++++ CHANGELOG.md | 3 + Makefile | 8 +- Package.resolved | 9 - Package.swift | 2 - Package@swift-5.7.swift | 2 - Package@swift-5.8.swift | 36 +++ Sources/Flare/Classes/Common/Types.swift | 2 + .../Formatters/NumberFormatter+.swift | 23 ++ .../Locale/Locale+CurrencyCode.swift | 20 ++ .../Classes/Extensions/ProductType+.swift | 40 +++ .../Classes/Helpers/Async/AsyncHandler.swift | 22 ++ .../AsyncSequence/AsyncSequence+Stream.swift | 15 ++ .../PaymentTransaction.swift | 4 + .../ITransactionListener.swift | 14 + .../TransactionListener.swift | 81 ++++++ Sources/Flare/Classes/Models/IAPError.swift | 32 +++ .../Internal/Protocols/ISKProduct.swift | 36 +++ .../Protocols/IStoreTransaction.swift | 32 +++ .../Models/Internal/SK1StoreProduct.swift | 72 +++++ .../Models/Internal/SK1StoreTransaction.swift | 65 +++++ .../Models/Internal/SK2StoreProduct.swift | 71 +++++ .../Models/Internal/SK2StoreTransaction.swift | 69 +++++ .../Models/Internal/StoreEnvironment.swift | 59 ++++ .../Classes/Models/ProductCategory.swift | 14 + .../Flare/Classes/Models/ProductType.swift | 21 ++ .../Flare/Classes/Models/StoreProduct.swift | 88 ++++++ .../Classes/Models/StoreTransaction.swift | 91 +++++++ .../Classes/Models/SubscriptionPeriod.swift | 100 +++++++ .../Classes/Models/VerificationError.swift | 10 + .../Providers/IAPProvider/IAPProvider.swift | 124 ++++++--- .../Providers/IAPProvider/IIAPProvider.swift | 57 +++- .../PaymentProvider/IPaymentProvider.swift | 2 +- .../PaymentProvider/PaymentProvider.swift | 13 + .../ProductProvider/IProductProvider.swift | 24 +- .../ProductProvider/ProductProvider.swift | 41 ++- .../PurchaseProvider/IPurchaseProvider.swift | 53 ++++ .../PurchaseProvider/PurchaseProvider.swift | 146 ++++++++++ .../IReceiptRefreshProvider.swift | 2 +- .../ReceiptRefreshProvider.swift | 25 ++ .../RefundProvider/IRefundProvider.swift | 1 + .../RefundProvider/RefundProvider.swift | 1 + Sources/Flare/Flare.swift | 44 ++- Sources/Flare/IFlare.swift | 59 +++- .../Helpers/WindowSceneFactory.swift | 21 -- .../Mocks/ProductProviderMock.swift | 26 -- Tests/FlareTests/UnitTests/FlareTests.swift | 132 +++------ .../Providers/IAPProviderTests.swift | 253 ++++-------------- .../Providers/ProductProviderTests.swift | 51 ++-- .../Providers/PurchaseProviderTests.swift | 104 +++++++ .../ReceiptRefreshProviderTests.swift | 43 ++- .../Providers/RefundProviderTests.swift | 108 ++++---- .../RefundRequestProviderTests.swift | 40 +-- .../Providers/SystemInfoProviderTests.swift | 50 ++-- .../TestHelpers/Extensions/Result+.swift | 26 ++ .../TestHelpers/Extensions/XCTestCase+.swift | 35 +++ .../TestHelpers/Fakes/SKProduct+Fake.swift | 15 ++ .../TestHelpers/Fakes/StoreProduct+Fake.swift | 13 + .../Fakes/StoreTransactionFake.swift | 12 + .../Helpers/AvailabilityChecker.swift | 14 + .../Helpers/PurchaseManagerTestHelper.swift | 0 .../Helpers/WindowSceneFactory.swift | 14 + .../Mocks/AppStoreReceiptProviderMock.swift | 0 .../TestHelpers}/Mocks/FileManagerMock.swift | 0 .../TestHelpers}/Mocks/IAPProviderMock.swift | 78 ++++-- .../Mocks/PaymentProviderMock.swift | 0 .../TestHelpers}/Mocks/PaymentQueueMock.swift | 0 .../Mocks/PaymentTransactionMock.swift | 0 .../TestHelpers}/Mocks/ProductMock.swift | 0 .../Mocks/ProductProviderMock.swift | 53 ++++ .../Mocks/ProductResponseMock.swift | 0 .../Mocks/ProductsRequestMock.swift | 0 .../Mocks/PurchaseProviderMock.swift | 81 ++++++ .../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 | 89 ++++++ .../Mocks/SystemInfoProviderMock.swift | 0 .../Stubs/StoreTransactionStub.swift | 57 ++++ Tests/IntegrationTests/Flare.storekit | 63 +++++ .../Helpers/Extensions/Result+.swift | 26 ++ .../Helpers/Extensions/XCTestCase+.swift | 35 +++ .../StoreSessionTestCase.swift | 31 +++ Tests/IntegrationTests/Tests/FlareTests.swift | 158 +++++++++++ .../Tests/IAPProviderTests.swift | 151 +++++++++++ .../Tests/ProductProviderHelper.swift | 44 +++ .../Tests/ProductProviderTests.swift | 59 ++++ .../Tests/PurchaseProviderTests.swift | 90 +++++++ Tests/TestPlans/AllTests.xctestplan | 44 +++ Tests/TestPlans/IntegrationTests.xctestplan | 37 +++ Tests/TestPlans/UnitTests.xctestplan | 37 +++ Tests/UnitTestHostApp/AppDelegate.swift | 30 +++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 98 +++++++ .../Assets.xcassets/Contents.json | 6 + Tests/UnitTestHostApp/Info.plist | 45 ++++ project.yml | 80 ++++++ scripts/setup_build_tools.sh | 8 + 106 files changed, 3643 insertions(+), 646 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/FlareTests.xcscheme create mode 100644 Package@swift-5.8.swift 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/Helpers/Async/AsyncHandler.swift 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/Internal/Protocols/ISKProduct.swift create mode 100644 Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK2StoreProduct.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/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/StoreTransaction.swift create mode 100644 Sources/Flare/Classes/Models/SubscriptionPeriod.swift create mode 100644 Sources/Flare/Classes/Models/VerificationError.swift create mode 100644 Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift create mode 100644 Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift delete mode 100644 Tests/FlareTests/Helpers/WindowSceneFactory.swift delete mode 100644 Tests/FlareTests/Mocks/ProductProviderMock.swift create mode 100644 Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift 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/Fakes/SKProduct+Fake.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Helpers/PurchaseManagerTestHelper.swift (100%) create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift 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 (60%) 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%) create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ProductResponseMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ProductsRequestMock.swift (100%) create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift 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%) create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/SystemInfoProviderMock.swift (100%) create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift create mode 100644 Tests/IntegrationTests/Flare.storekit create mode 100644 Tests/IntegrationTests/Helpers/Extensions/Result+.swift create mode 100644 Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift create mode 100644 Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift create mode 100644 Tests/IntegrationTests/Tests/FlareTests.swift create mode 100644 Tests/IntegrationTests/Tests/IAPProviderTests.swift create mode 100644 Tests/IntegrationTests/Tests/ProductProviderHelper.swift 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 create mode 100644 Tests/UnitTestHostApp/AppDelegate.swift create mode 100644 Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Tests/UnitTestHostApp/Assets.xcassets/Contents.json create mode 100644 Tests/UnitTestHostApp/Info.plist create mode 100644 project.yml create mode 100644 scripts/setup_build_tools.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d2fe1aa1..ca56e598a 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 @@ -28,56 +24,202 @@ 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" - timeout-minutes: 10 + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 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: ${{ 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 "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: "./${{ matrix.sdk }}.xcresult" - Beta: - name: "Test Betas" - runs-on: macos-13 + xcode_archive_path: test_output/${{ matrix.name }}.xcresult + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: test_output + + iOS: + 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=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 + 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 AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: test_output + + tvOS: + 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=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 + 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 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 }} + path: test_output + + watchOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} env: - DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" - timeout-minutes: 10 + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - destination: "OS=1.0,name=Apple Vision Pro" - name: "visionOS" - scheme: "Flare" + - 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 "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean \ No newline at end of file + 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 }} + path: test_output + + 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 + + merge-test-reports: + 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/final.xcresult + - name: Upload Merged Artifact + uses: actions/upload-artifact@v4 + with: + name: MergedResult + path: test_output/final + + # Beta: + # name: ${{ matrix.name }} + # runs-on: firebreak + # 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/.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 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/.swiftlint.yml b/.swiftlint.yml index ca37591b9..66d17c089 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 @@ -10,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/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme index 46bcfc41a..866e55a53 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme @@ -34,6 +34,20 @@ ReferencedContainer = "container:"> + + + + + + + + + + + + + + + + + + + + + + + + + + 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). diff --git a/Makefile b/Makefile index 856d64b45..f11937816 100644 --- a/Makefile +++ b/Makefile @@ -16,4 +16,10 @@ lint: fmt: mint run swiftformat Sources Tests -.PHONY: all bootstrap hook mint lint fmt \ No newline at end of file +generate: + xcodegen generate + +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/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 b/Package.swift index 1a48e0820..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,7 +33,6 @@ let package = Package( name: "FlareTests", dependencies: [ "Flare", - .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), ] ), diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index f933cf240..e645e1d10 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,7 +29,6 @@ let package = Package( name: "FlareTests", dependencies: [ "Flare", - .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), ] ), 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"), + ] + ), + ] +) 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/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/Helpers/Async/AsyncHandler.swift b/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift new file mode 100644 index 000000000..f819b1bf5 --- /dev/null +++ b/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +enum AsyncHandler { + static func call( + 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/Helpers/AsyncSequence/AsyncSequence+Stream.swift b/Sources/Flare/Classes/Helpers/AsyncSequence/AsyncSequence+Stream.swift new file mode 100644 index 000000000..ef1f60823 --- /dev/null +++ b/Sources/Flare/Classes/Helpers/AsyncSequence/AsyncSequence+Stream.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension AsyncSequence { + func toAsyncStream() -> AsyncStream { + var asyncIterator = makeAsyncIterator() + return AsyncStream { + try? await asyncIterator.next() + } + } +} 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/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..83537685e 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -30,12 +30,44 @@ 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 } 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/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/Protocols/IStoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift new file mode 100644 index 000000000..db8a4cce7 --- /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/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift new file mode 100644 index 000000000..92d48806c --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -0,0 +1,72 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK1StoreProduct + +final class SK1StoreProduct { + // MARK: Properties + + /// The store kit product. + 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/SK1StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift new file mode 100644 index 000000000..e7f02aa29 --- /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. + 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/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift new file mode 100644 index 000000000..c8fa0527d --- /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. + 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/Internal/SK2StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift new file mode 100644 index 000000000..5ccde0c40 --- /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. + 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/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..d240cbcde --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -0,0 +1,88 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +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 + + /// <#Description#> + var underlyingProduct: ISKProduct { product } + + // MARK: Initialization + + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter product: The StoreKit product. + init(_ product: ISKProduct) { + self.product = product + } +} + +// 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)) + } +} + +// 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/StoreTransaction.swift b/Sources/Flare/Classes/Models/StoreTransaction.swift new file mode 100644 index 000000000..fa16b2478 --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreTransaction.swift @@ -0,0 +1,91 @@ +// +// 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. + 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 + } +} + +// MARK: Equatable + +extension StoreTransaction: Equatable { + public static func == (lhs: StoreTransaction, rhs: StoreTransaction) -> Bool { + lhs.transactionIdentifier == rhs.transactionIdentifier + } +} 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 + } + } +} 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 85fc9be86..f2ed8c0a0 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -5,21 +5,35 @@ 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 - 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 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. + /// - 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() @@ -27,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 } @@ -38,15 +52,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) @@ -54,34 +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 - } - - let payment = SKPayment(product: product) - - self.paymentProvider.add(payment: payment) { _, result in - switch result { - case let .success(transaction): - completion(.success(PaymentTransaction(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 -> PaymentTransaction { + func purchase(product: StoreProduct) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in - purchase(productID: productID) { result in + self.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(product: product, options: options) { result in continuation.resume(with: result) } } @@ -110,24 +140,16 @@ final class IAPProvider: IIAPProvider { } } - func finish(transaction: PaymentTransaction) { - paymentProvider.finish(transaction: transaction) + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + purchaseProvider.finish(transaction: transaction, completion: completion) } 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 @@ -139,4 +161,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..ac7cf0bac 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,18 +24,31 @@ 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. + /// 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. /// @@ -43,12 +56,36 @@ 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. + /// - completion: The closure to be executed once the purchase is complete. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> PaymentTransaction + @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. + /// + /// - 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. /// @@ -65,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/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/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift new file mode 100644 index 000000000..a02d3fad4 --- /dev/null +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -0,0 +1,53 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +public typealias PurchaseCompletionHandler = @MainActor @Sendable (Result) -> Void + +// MARK: - IPurchaseProvider + +protocol IPurchaseProvider { + /// Removes a finished (i.e. failed or completed) transaction from the queue. + /// Attempting to finish a purchasing transaction will throw an exception. + /// + /// - 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. + /// + /// - 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() + + /// 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, + options: Set, + completion: @escaping PurchaseCompletionHandler + ) +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift new file mode 100644 index 000000000..50cf3a690 --- /dev/null +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -0,0 +1,146 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PurchaseProvider + +final class PurchaseProvider { + // MARK: Properties + + /// The provider is responsible for making in-app payments. + private let paymentProvider: IPaymentProvider + /// The transaction listener. + private let transactionListener: ITransactionListener? + + // MARK: Initialization + + /// Creates a new `PurchaseProvider` isntance. + /// + /// - Parameters: + /// - paymentProvider: The provider is responsible for purchasing products. + /// - transactionListener: The transaction listener. + 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, + options: Set? = nil, + 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: options ?? []) + }) + } +} + +// MARK: IPurchaseProvider + +extension PurchaseProvider: IPurchaseProvider { + 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) + } + } + + @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: 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>?) { + 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/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/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 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/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index a4505be9b..159ac18fc 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -11,40 +11,46 @@ 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) } - 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)) @@ -54,9 +60,31 @@ extension Flare: IFlare { } } - public func purchase(productID: String) async throws -> PaymentTransaction { + 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 { + guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } + return try await iapProvider.purchase(product: product, options: options) } public func receipt(completion: @escaping Closure>) { @@ -74,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 6d6a146b7..69cc89aa3 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,31 +22,68 @@ 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. + /// 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 -> PaymentTransaction + func purchase(product: StoreProduct) 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. /// @@ -63,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/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/Helpers/WindowSceneFactory.swift deleted file mode 100644 index 337484a51..000000000 --- a/Tests/FlareTests/Helpers/WindowSceneFactory.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// 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/ProductProviderMock.swift b/Tests/FlareTests/Mocks/ProductProviderMock.swift deleted file mode 100644 index 5480426d4..000000000 --- a/Tests/FlareTests/Mocks/ProductProviderMock.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -@testable import Flare -import class StoreKit.SKProduct - -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>? - - func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { - invokedFetch = true - invokedFetchCount += 1 - invokedFetchParameters = (productIDs, requestID, completion) - invokedFetchParamtersList.append((productIDs, requestID, completion)) - - if let result = stubbedFetchResult { - completion(result) - } - } -} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 87aed5ebe..33cc4debc 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -13,19 +13,20 @@ class FlareTests: XCTestCase { // 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) @@ -41,11 +42,15 @@ 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 - let products = try await flare.fetch(productIDs: .ids) + let products = try await sut.fetch(productIDs: .ids) // then XCTAssertEqual(products, productMocks) @@ -56,54 +61,50 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = true // when - flare.purchase(productID: .productID, completion: { _ in }) + sut.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() { + func test_thatFlareThrowsAnError_whenUserCannotMakePayments() { // given iapProviderMock.stubbedCanMakePayments = false // when - flare.purchase(productID: .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 = PaymentTransaction(PaymentTransactionMock()) + let paymentTransaction = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true // when - var transaction: PaymentTransaction? - flare.purchase(productID: .productID, completion: { result in - if case let .success(result) = result { - transaction = result - } + var transaction: IStoreTransaction? + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + transaction = result.success }) iapProviderMock.invokedPurchaseParameters?.completion(.success(paymentTransaction)) // then XCTAssertTrue(iapProviderMock.invokedPurchase) - XCTAssertEqual(transaction, paymentTransaction) + 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(productID: .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)) @@ -115,15 +116,10 @@ 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? - do { - _ = try await flare.purchase(productID: .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) @@ -132,34 +128,24 @@ 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 iapError: IAPError? - do { - transaction = try await flare.purchase(productID: .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, transactionMock) + 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 @@ -170,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 @@ -184,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) @@ -206,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() { @@ -222,7 +200,7 @@ class FlareTests: XCTestCase { let transaction = PaymentTransaction(PaymentTransactionMock()) // when - flare.finish(transaction: transaction) + sut.finish(transaction: StoreTransaction(paymentTransaction: transaction), completion: nil) // then XCTAssertTrue(iapProviderMock.invokedFinishTransaction) @@ -230,39 +208,11 @@ class FlareTests: XCTestCase { func test_thatFlareAddsTransactionObserver() { // when - flare.addTransactionObserver(fallbackHandler: { _ in }) + sut.addTransactionObserver(fallbackHandler: { _ in }) // 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/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 06c322ac5..319d97f15 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -14,11 +14,11 @@ 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! - private var iapProvider: IIAPProvider! + private var sut: IIAPProvider! // MARK: - XCTestCase @@ -26,13 +26,13 @@ class IAPProviderTests: XCTestCase { super.setUp() paymentQueueMock = PaymentQueueMock() productProviderMock = ProductProviderMock() - paymentProviderMock = PaymentProviderMock() + purchaseProvider = PurchaseProviderMock() receiptRefreshProviderMock = ReceiptRefreshProviderMock() refundProviderMock = RefundProviderMock() - iapProvider = IAPProvider( + sut = IAPProvider( paymentQueue: paymentQueueMock, productProvider: productProviderMock, - paymentProvider: paymentProviderMock, + purchaseProvider: purchaseProvider, receiptRefreshProvider: receiptRefreshProviderMock, refundProvider: refundProviderMock ) @@ -41,10 +41,10 @@ class IAPProviderTests: XCTestCase { override func tearDown() { paymentQueueMock = nil productProviderMock = nil - paymentProviderMock = nil + purchaseProvider = nil receiptRefreshProviderMock = nil refundProviderMock = nil - iapProvider = nil + sut = nil super.tearDown() } @@ -55,12 +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) @@ -70,15 +72,15 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderPurchasesProduct() throws { // when - iapProvider.purchase(productID: .productID, completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then - XCTAssertTrue(productProviderMock.invokedFetch) + XCTAssertTrue(purchaseProvider.invokedPurchase) } func test_thatIAPProviderRefreshesReceipt() { // when - iapProvider.refreshReceipt(completion: { _ in }) + sut.refreshReceipt(completion: { _ in }) // then XCTAssertTrue(receiptRefreshProviderMock.invokedRefresh) @@ -89,168 +91,92 @@ class IAPProviderTests: XCTestCase { let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) // when - iapProvider.finish(transaction: PaymentTransaction(transaction)) + sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction)), completion: nil) // then - XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) + XCTAssertTrue(purchaseProvider.invokedFinish) } func test_thatIAPProviderAddsTransactionObserver() { // when - iapProvider.addTransactionObserver(fallbackHandler: { _ in }) + sut.addTransactionObserver(fallbackHandler: { _ in }) // then - XCTAssertTrue(paymentProviderMock.invokedFallbackHandler) - XCTAssertTrue(paymentProviderMock.invokedAddTransactionObserver) + XCTAssertTrue(purchaseProvider.invokedAddTransactionObserver) } func test_thatIAPProviderRemovesTransactionObserver() { // when - iapProvider.removeTransactionObserver() + sut.removeTransactionObserver() // then - XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) + XCTAssertTrue(purchaseProvider.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) + let products = try await sut.fetch(productIDs: .productIDs) // then - XCTAssertEqual(productsMock, products) + XCTAssertEqual(productsMock.count, products.count) } - func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async { + func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // given 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) } - 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_thatIAPProviderReturnsPaymentTransaction_whenProductsExist() { - // given - let paymentTransactionMock = PaymentTransactionMock() - productProviderMock.stubbedFetchResult = .success([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([ProductMock()]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .failure(.unknown)) + productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) + purchaseProvider.stubbedPurchaseCompletionResult = (.failure(.unknown), ()) // when - var errorResult: Error? - iapProvider.purchase(productID: .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() { // given - productProviderMock.stubbedFetchResult = .failure(.storeProductNotAvailable) - - // when - var errorResult: Error? - iapProvider.purchase(productID: .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) - } - - func test_thatIAPProviderPurchasesForAProduct_whenProductsExist() async throws { - // given - let transactionMock = SKPaymentTransaction() - productProviderMock.stubbedFetchResult = .success([ProductMock()]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(transactionMock)) + purchaseProvider.stubbedPurchaseCompletionResult = (.failure(IAPError.unknown), ()) // when - let transactionResult = try await iapProvider.purchase(productID: .productID) + var error: Error? + sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } // then - XCTAssertEqual(transactionMock, transactionResult.skTransaction) + 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() { @@ -259,15 +185,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() { @@ -276,15 +198,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 { @@ -293,7 +211,7 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - let receipt = try await iapProvider.refreshReceipt() + let receipt = try await sut.refreshReceipt() // then XCTAssertEqual(receipt, .receipt) @@ -305,77 +223,11 @@ 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() - 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 - } - } - - // 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 @@ -383,6 +235,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 c2f1ada71..fc0a2edf1 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,21 +25,21 @@ 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<[SKProduct], IAPError>? + var fetchResult: Result<[SK1StoreProduct], IAPError>? let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let response = ProductResponseMock() @@ -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,41 @@ class ProductProviderTests: XCTestCase { } } - func test_thatProductProviderReturnsProducts_whenRequestProductsWithValidProductIDs() { + func test_thatProductProviderReturnsProducts_whenRequestProductsAreFetchedWithValidProductIDs() { // given - var fetchResult: Result<[SKProduct], 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, response.products) - } else { - XCTFail() - } + XCTAssertEqual(products?.map(\.product), response.products) } func test_thatProductProviderHandlesError_whenRequestDidFailWithError() { // given - var fetchResult: Result<[SKProduct], 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) } } // 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 new file mode 100644 index 000000000..af4b7a49a --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -0,0 +1,104 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import StoreKitTest +import XCTest + +// MARK: - PurchaseProviderTests + +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") + } + } + } + + func test_thatPurchaseProviderFinishesTransaction() { + // given + let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) + + // when + sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction)), completion: nil) + + // then + XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) + } + + func test_thatPurchaseProviderAddsTransactionObserver_whenPaymentDidSuccess() { + // 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) + } +} 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..4dbe95d6e 100644 --- a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift @@ -34,75 +34,71 @@ // 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 { - // 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/RefundRequestProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift index 6488c5ea6..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 @@ -28,24 +30,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 @@ -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..5d92412bb 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() { @@ -29,31 +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() { - // when - var receivedError: Error? - do { - _ = try sut.currentScene - } catch { - receivedError = error - } - - // then - XCTAssertEqual(receivedError 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 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/Fakes/SKProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift new file mode 100644 index 000000000..a748b6270 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/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/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift new file mode 100644 index 000000000..4f93ff255 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/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/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift new file mode 100644 index 000000000..002b7311b --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/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/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift new file mode 100644 index 000000000..7cec99218 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/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/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/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift new file mode 100644 index 000000000..4c21b2c38 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + import UIKit + + final class WindowSceneFactory { + static func makeWindowScene() -> UIWindowScene { + UIApplication.shared.connectedScenes.first as! UIWindowScene + } + } +#endif 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 60% rename from Tests/FlareTests/Mocks/IAPProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index 5b97f58eb..e6a194485 100644 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/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) @@ -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 @@ -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, ()) @@ -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, ()) @@ -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 stubbedAsyncPurchase: PaymentTransaction! + var invokedAsyncPurchaseParameters: (product: StoreProduct, Void)? + var invokedAsyncPurchaseParametersList = [(product: StoreProduct, Void)?]() + var stubbedAsyncPurchase: StoreTransaction! - func purchase(productID: String) async throws -> PaymentTransaction { + 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,44 @@ 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)]() + 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> + ) { + invokedPurchaseWithOptions = true + invokedPurchaseWithOptionsCount += 1 + invokedPurchaseWithOptionsParameters = (product, options) + invokedPurchaseWithOptionsParametersList.append((product, options)) + + if let result = stubbedPurchaseWithOptionsResult { + completion(result) + } + } + + 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/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/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift new file mode 100644 index 000000000..38a26470a --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift @@ -0,0 +1,53 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +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<[SK1StoreProduct], IAPError>? + + func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { + invokedFetch = true + invokedFetchCount += 1 + invokedFetchParameters = (productIDs, requestID, completion) + invokedFetchParamtersList.append((productIDs, requestID, completion)) + + if let result = stubbedFetchResult { + completion(result) + } + } + + var invokedAsyncFetch = false + var invokedAsyncFetchCount = 0 + var invokedAsyncFetchParameters: (productIDs: Set, Void)? + var invokedAsyncFetchParamtersList = [(productIDs: Set, Void)]() + 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] { + 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/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/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift new file mode 100644 index 000000000..b24200266 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -0,0 +1,81 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit + +final class PurchaseProviderMock: IPurchaseProvider { + var invokedFinish = false + var invokedFinishCount = 0 + var invokedFinishParameters: (transaction: StoreTransaction, Void)? + var invokedFinishParametersList = [(transaction: StoreTransaction, Void)]() + + func finish(transaction: StoreTransaction, completion _: (@Sendable () -> Void)?) { + 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) + } + } + + 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/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/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift new file mode 100644 index 000000000..225e403ef --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/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/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/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift b/Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift new file mode 100644 index 000000000..7b8a0c7cb --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/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/IntegrationTests/Flare.storekit b/Tests/IntegrationTests/Flare.storekit new file mode 100644 index 000000000..102d022b8 --- /dev/null +++ b/Tests/IntegrationTests/Flare.storekit @@ -0,0 +1,63 @@ +{ + "identifier" : "95D98A48", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "169432A7", + "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" : "Consumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "33E61322", + "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" + }, + { + "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" : { + + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 2, + "minor" : 0 + } +} 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/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift new file mode 100644 index 000000000..4e33a5234 --- /dev/null +++ b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift @@ -0,0 +1,31 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKitTest +import XCTest + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) +class StoreSessionTestCase: XCTestCase { + // MARK: Properties + + var session: SKTestSession? + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + + session = try? SKTestSession(configurationFileNamed: "Flare") + session?.resetToDefaultState() + session?.askToBuyEnabled = false + session?.disableDialogs = true + } + + override func tearDown() { + session?.clearTransactions() + session = nil + super.tearDown() + } +} diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift new file mode 100644 index 000000000..c3ff08793 --- /dev/null +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -0,0 +1,158 @@ +// +// 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 + let 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 + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif + } +} + +// 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/IntegrationTests/Tests/ProductProviderHelper.swift b/Tests/IntegrationTests/Tests/ProductProviderHelper.swift new file mode 100644 index 000000000..2deb3d05b --- /dev/null +++ b/Tests/IntegrationTests/Tests/ProductProviderHelper.swift @@ -0,0 +1,44 @@ +// +// 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: [.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 +// } +// } +} + +// MARK: - Constants + +private extension String { + static let testPurchase1ID = "com.flare.test_purchase_1" + static let testPurchase2ID = "com.flare.test_purchase_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/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/UnitTestHostApp/AppDelegate.swift b/Tests/UnitTestHostApp/AppDelegate.swift new file mode 100644 index 000000000..4f3e9b07c --- /dev/null +++ b/Tests/UnitTestHostApp/AppDelegate.swift @@ -0,0 +1,30 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(macOS) + + import Cocoa + + @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 {} + +#endif diff --git a/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..9221b9bb1 --- /dev/null +++ b/Tests/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/UnitTestHostApp/Assets.xcassets/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Tests/UnitTestHostApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UnitTestHostApp/Info.plist b/Tests/UnitTestHostApp/Info.plist new file mode 100644 index 000000000..c468f022f --- /dev/null +++ b/Tests/UnitTestHostApp/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + UnitTestsHostApp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/project.yml b/project.yml new file mode 100644 index 000000000..59e13d92d --- /dev/null +++ b/project.yml @@ -0,0 +1,80 @@ +name: Flare +options: + deploymentTarget: + iOS: 13.0 + macOS: 10.15 + tvOS: 13.0 + watchOS: 7.0 +packages: + # External + + Concurrency: + url: https://github.com/space-code/concurrency.git + from: 0.0.1 +targets: + UnitTestHostApp: + type: application + supportedDestinations: [iOS, tvOS, macOS] + 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/IntegrationTests/Flare.storekit" + testTargets: + - IntegrationTests + Flare: + type: framework + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - package: Concurrency + product: Concurrency + settings: + 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: + 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: + - 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.storekit-unit-tests + sources: + - Tests/IntegrationTests \ No newline at end of file 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