Test IAPManager (#903)
Refactoring: - Split tests - Report purchase to receipt reader (real impl does nothing) - Abstract BetaChecker from now TestFlightChecker Bugfixing: - Fix duplicated code in fetchLevelIfNeeded() - Fix observeObjects() missing .didUpdate publisher
This commit is contained in:
parent
2478fb204b
commit
842375ffce
|
@ -28,4 +28,6 @@ import Foundation
|
|||
|
||||
public protocol AppReceiptReader {
|
||||
func receipt(at userLevel: AppUserLevel) async -> InAppReceipt?
|
||||
|
||||
func addPurchase(with identifier: String) async
|
||||
}
|
||||
|
|
|
@ -56,15 +56,37 @@ public actor FakeAppReceiptReader: AppReceiptReader {
|
|||
localReceipt
|
||||
}
|
||||
|
||||
public func addPurchase(with identifier: String) {
|
||||
public func addPurchase(with identifier: String) async {
|
||||
await addPurchase(with: identifier, expirationDate: nil, cancellationDate: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension FakeAppReceiptReader {
|
||||
public func addPurchase(
|
||||
with product: AppProduct,
|
||||
expirationDate: Date? = nil,
|
||||
cancellationDate: Date? = nil
|
||||
) async {
|
||||
await addPurchase(
|
||||
with: product.rawValue,
|
||||
expirationDate: expirationDate,
|
||||
cancellationDate: cancellationDate
|
||||
)
|
||||
}
|
||||
|
||||
public func addPurchase(
|
||||
with identifier: String,
|
||||
expirationDate: Date? = nil,
|
||||
cancellationDate: Date? = nil
|
||||
) async {
|
||||
guard let localReceipt else {
|
||||
fatalError("Call setReceipt() first")
|
||||
}
|
||||
var purchaseReceipts = localReceipt.purchaseReceipts ?? []
|
||||
purchaseReceipts.append(.init(
|
||||
productIdentifier: identifier,
|
||||
expirationDate: nil,
|
||||
cancellationDate: nil,
|
||||
expirationDate: expirationDate,
|
||||
cancellationDate: cancellationDate,
|
||||
originalPurchaseDate: nil
|
||||
))
|
||||
let newReceipt = InAppReceipt(
|
||||
|
|
|
@ -36,6 +36,8 @@ public final class IAPManager: ObservableObject {
|
|||
|
||||
private let receiptReader: AppReceiptReader
|
||||
|
||||
private let betaChecker: BetaChecker
|
||||
|
||||
private let unrestrictedFeatures: Set<AppFeature>
|
||||
|
||||
private let productsAtBuild: BuildProducts<AppProduct>?
|
||||
|
@ -57,12 +59,14 @@ public final class IAPManager: ObservableObject {
|
|||
customUserLevel: AppUserLevel? = nil,
|
||||
inAppHelper: any AppProductHelper,
|
||||
receiptReader: AppReceiptReader,
|
||||
betaChecker: BetaChecker,
|
||||
unrestrictedFeatures: Set<AppFeature> = [],
|
||||
productsAtBuild: BuildProducts<AppProduct>? = nil
|
||||
) {
|
||||
self.customUserLevel = customUserLevel
|
||||
self.inAppHelper = inAppHelper
|
||||
self.receiptReader = receiptReader
|
||||
self.betaChecker = betaChecker
|
||||
self.unrestrictedFeatures = unrestrictedFeatures
|
||||
self.productsAtBuild = productsAtBuild
|
||||
userLevel = .undefined
|
||||
|
@ -90,6 +94,7 @@ extension IAPManager {
|
|||
public func purchase(_ purchasableProduct: InAppProduct) async throws -> InAppPurchaseResult {
|
||||
let result = try await inAppHelper.purchase(purchasableProduct)
|
||||
if result == .done {
|
||||
await receiptReader.addPurchase(with: purchasableProduct.productIdentifier)
|
||||
await reloadReceipt()
|
||||
}
|
||||
return result
|
||||
|
@ -235,9 +240,6 @@ extension IAPManager {
|
|||
Task {
|
||||
await fetchLevelIfNeeded()
|
||||
do {
|
||||
let products = try await inAppHelper.fetchProducts()
|
||||
pp_log(.App.iap, .info, "Available in-app products: \(products.map(\.key))")
|
||||
|
||||
inAppHelper
|
||||
.didUpdate
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -247,6 +249,9 @@ extension IAPManager {
|
|||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
let products = try await inAppHelper.fetchProducts()
|
||||
pp_log(.App.iap, .info, "Available in-app products: \(products.map(\.key))")
|
||||
} catch {
|
||||
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
|
||||
}
|
||||
|
@ -262,14 +267,10 @@ private extension IAPManager {
|
|||
if let customUserLevel {
|
||||
userLevel = customUserLevel
|
||||
pp_log(.App.iap, .info, "App level (custom): \(userLevel)")
|
||||
} else {
|
||||
let checker = SandboxChecker()
|
||||
let isBeta = await checker.isBeta()
|
||||
guard userLevel == .undefined else {
|
||||
return
|
||||
}
|
||||
userLevel = isBeta ? .beta : .freemium
|
||||
pp_log(.App.iap, .info, "App level: \(userLevel)")
|
||||
return
|
||||
}
|
||||
let isBeta = await betaChecker.isBeta()
|
||||
userLevel = isBeta ? .beta : .freemium
|
||||
pp_log(.App.iap, .info, "App level: \(userLevel)")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,6 +53,10 @@ public actor FallbackReceiptReader: AppReceiptReader {
|
|||
pendingTask = nil
|
||||
return receipt
|
||||
}
|
||||
|
||||
public func addPurchase(with identifier: String) async {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private extension FallbackReceiptReader {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// BetaChecker.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/21/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
//
|
||||
// This file is part of Passepartout.
|
||||
//
|
||||
// Passepartout is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Passepartout is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol BetaChecker {
|
||||
func isBeta() async -> Bool
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SandboxChecker.swift
|
||||
// TestFlightChecker.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 5/18/22.
|
||||
|
@ -28,7 +28,7 @@ import Foundation
|
|||
// https://stackoverflow.com/a/32238344/784615
|
||||
// https://gist.github.com/lukaskubanek/cbfcab29c0c93e0e9e0a16ab09586996
|
||||
|
||||
public final class SandboxChecker {
|
||||
public final class TestFlightChecker: BetaChecker {
|
||||
public init() {
|
||||
}
|
||||
|
||||
|
@ -41,7 +41,7 @@ public final class SandboxChecker {
|
|||
|
||||
// MARK: Shared
|
||||
|
||||
private extension SandboxChecker {
|
||||
private extension TestFlightChecker {
|
||||
|
||||
// IMPORTANT: check Mac first because os(iOS) holds true for Catalyst
|
||||
func verifyBetaBuild() -> Bool {
|
||||
|
@ -62,7 +62,7 @@ private extension SandboxChecker {
|
|||
// MARK: iOS
|
||||
|
||||
#if os(iOS)
|
||||
private extension SandboxChecker {
|
||||
private extension TestFlightChecker {
|
||||
var isiOSSandboxBuild: Bool {
|
||||
bundle.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ private extension SandboxChecker {
|
|||
// MARK: macOS
|
||||
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
private extension SandboxChecker {
|
||||
private extension TestFlightChecker {
|
||||
var isMacTestFlightBuild: Bool {
|
||||
var status = noErr
|
||||
|
|
@ -36,6 +36,7 @@ extension AppContext {
|
|||
let iapManager = IAPManager(
|
||||
inAppHelper: FakeAppProductHelper(),
|
||||
receiptReader: FakeAppReceiptReader(),
|
||||
betaChecker: TestFlightChecker(),
|
||||
unrestrictedFeatures: [
|
||||
.interactiveLogin,
|
||||
.onDemand
|
||||
|
|
|
@ -23,27 +23,66 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Combine
|
||||
@testable import CommonIAP
|
||||
@testable import CommonLibrary
|
||||
import CommonUtils
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class IAPManagerTests: XCTestCase {
|
||||
// private let inApp = FakeAppProductHelper()
|
||||
|
||||
private let olderBuildNumber = 500
|
||||
|
||||
private let defaultBuildNumber = 1000
|
||||
|
||||
private let newerBuildNumber = 1500
|
||||
|
||||
private var subscriptions: Set<AnyCancellable> = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
// MARK: - Actions
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenProducts_whenFetchAppProducts_thenReturnsCorrespondingInAppProducts() async throws {
|
||||
let reader = FakeAppReceiptReader()
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
// MARK: Build products
|
||||
let appProducts: [AppProduct] = [
|
||||
.Full.iOS,
|
||||
.Donations.huge
|
||||
]
|
||||
let inAppProducts = try await sut.purchasableProducts(for: appProducts)
|
||||
inAppProducts.enumerated().forEach {
|
||||
XCTAssertEqual($0.element.productIdentifier, appProducts[$0.offset].rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenProducts_whenPurchase_thenIsAddedToPurchasedProducts() async throws {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: .max, products: [])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
let appleTV: AppProduct = .Features.appleTV
|
||||
XCTAssertFalse(sut.purchasedProducts.contains(appleTV))
|
||||
do {
|
||||
let purchasable = try await sut.purchasableProducts(for: [appleTV])
|
||||
let purchasableAppleTV = try XCTUnwrap(purchasable.first)
|
||||
let result = try await sut.purchase(purchasableAppleTV)
|
||||
if result == .done {
|
||||
XCTAssertTrue(sut.purchasedProducts.contains(appleTV))
|
||||
} else {
|
||||
XCTFail("Unexpected purchase() result: \(result)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Unexpected purchase() failure: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Build products
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenBuildProducts_whenOlder_thenFullVersion() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: olderBuildNumber, identifiers: [])
|
||||
|
@ -69,9 +108,11 @@ extension IAPManagerTests {
|
|||
await sut.reloadReceipt()
|
||||
XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Eligibility
|
||||
// MARK: - Eligibility
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenPurchasedFeature_whenReloadReceipt_thenIsEligible() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
@ -155,13 +196,14 @@ extension IAPManagerTests {
|
|||
}
|
||||
}
|
||||
|
||||
func test_givenAppleTV_thenIsEligibleForAppleTV() async {
|
||||
func test_givenAppleTV_thenIsEligibleForAppleTVAndSharing() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Features.appleTV])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
XCTAssertTrue(sut.isEligible(for: .appleTV))
|
||||
XCTAssertTrue(sut.isEligible(for: .sharing))
|
||||
}
|
||||
|
||||
func test_givenPlatformVersion_thenIsFullVersionForPlatform() async {
|
||||
|
@ -194,13 +236,38 @@ extension IAPManagerTests {
|
|||
#endif
|
||||
}
|
||||
|
||||
// MARK: App level
|
||||
func test_givenUser_thenIsNotEligibleForFeedback() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
XCTAssertFalse(sut.isEligibleForFeedback())
|
||||
}
|
||||
|
||||
func test_givenBeta_thenIsEligibleForFeedback() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: .max, identifiers: [])
|
||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
|
||||
await sut.reloadReceipt()
|
||||
XCTAssertTrue(sut.isEligibleForFeedback())
|
||||
}
|
||||
|
||||
func test_givenPayingUser_thenIsEligibleForFeedback() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: .max, products: [.Full.iOS])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
await sut.reloadReceipt()
|
||||
XCTAssertTrue(sut.isEligibleForFeedback())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App level
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenBetaApp_thenIsRestricted() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
XCTAssertTrue(sut.isRestricted)
|
||||
XCTAssertTrue(sut.userLevel.isRestricted)
|
||||
}
|
||||
|
||||
|
@ -280,17 +347,115 @@ extension IAPManagerTests {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Beta
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenChecker_whenReloadReceipt_thenIsBeta() async {
|
||||
let betaChecker = MockBetaChecker()
|
||||
betaChecker.isBeta = true
|
||||
let sut = IAPManager(receiptReader: FakeAppReceiptReader(), betaChecker: betaChecker)
|
||||
XCTAssertEqual(sut.userLevel, .undefined)
|
||||
await sut.reloadReceipt()
|
||||
XCTAssertEqual(sut.userLevel, .beta)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receipts
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenReceipts_whenReloadReceipt_thenPublishesEligibleFeatures() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: .max, products: [
|
||||
.Features.appleTV,
|
||||
.Features.trustedNetworks
|
||||
])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
let exp = expectation(description: "Eligible features")
|
||||
sut
|
||||
.$eligibleFeatures
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
exp.fulfill()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
await fulfillment(of: [exp], timeout: 0.1)
|
||||
|
||||
XCTAssertEqual(sut.eligibleFeatures, [
|
||||
.appleTV,
|
||||
.onDemand,
|
||||
.sharing // implied by Apple TV purchase
|
||||
])
|
||||
}
|
||||
|
||||
func test_givenInvalidReceipts_whenReloadReceipt_thenSkipsInvalid() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: .max, products: [])
|
||||
await reader.addPurchase(with: "foobar")
|
||||
await reader.addPurchase(with: .Features.allProviders, expirationDate: Date().addingTimeInterval(-10))
|
||||
await reader.addPurchase(with: .Features.appleTV)
|
||||
await reader.addPurchase(with: .Features.networkSettings, expirationDate: Date().addingTimeInterval(10))
|
||||
await reader.addPurchase(with: .Full.iOS, cancellationDate: Date().addingTimeInterval(-60))
|
||||
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
await sut.reloadReceipt()
|
||||
|
||||
XCTAssertEqual(sut.eligibleFeatures, [
|
||||
.appleTV,
|
||||
.dns,
|
||||
.httpProxy,
|
||||
.routing,
|
||||
.sharing
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observation
|
||||
|
||||
extension IAPManagerTests {
|
||||
func test_givenManager_whenObserveObjects_thenReloadsReceipt() async {
|
||||
let reader = FakeAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: .max, products: [.Full.allPlatforms])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
XCTAssertEqual(sut.userLevel, .undefined)
|
||||
XCTAssertTrue(sut.eligibleFeatures.isEmpty)
|
||||
|
||||
let exp = expectation(description: "Reload receipt")
|
||||
sut
|
||||
.$eligibleFeatures
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
exp.fulfill()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
sut.observeObjects()
|
||||
await fulfillment(of: [exp], timeout: 0.1)
|
||||
|
||||
XCTAssertNotEqual(sut.userLevel, .undefined)
|
||||
XCTAssertFalse(sut.eligibleFeatures.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension IAPManager {
|
||||
convenience init(
|
||||
customUserLevel: AppUserLevel? = nil,
|
||||
inAppHelper: (any AppProductHelper)? = nil,
|
||||
receiptReader: AppReceiptReader,
|
||||
betaChecker: BetaChecker? = nil,
|
||||
unrestrictedFeatures: Set<AppFeature> = [],
|
||||
productsAtBuild: BuildProducts<AppProduct>? = nil
|
||||
) {
|
||||
self.init(
|
||||
customUserLevel: customUserLevel,
|
||||
inAppHelper: FakeAppProductHelper(),
|
||||
inAppHelper: inAppHelper ?? FakeAppProductHelper(),
|
||||
receiptReader: receiptReader,
|
||||
betaChecker: betaChecker ?? TestFlightChecker(),
|
||||
unrestrictedFeatures: unrestrictedFeatures,
|
||||
productsAtBuild: productsAtBuild
|
||||
)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// MockBetaChecker.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/21/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
//
|
||||
// This file is part of Passepartout.
|
||||
//
|
||||
// Passepartout is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Passepartout is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonUtils
|
||||
import Foundation
|
||||
|
||||
final class MockBetaChecker: BetaChecker {
|
||||
var isBeta = false
|
||||
|
||||
func isBeta() async -> Bool {
|
||||
isBeta
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue