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:
Davide 2024-11-21 18:57:32 +01:00 committed by GitHub
parent 2478fb204b
commit 842375ffce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 287 additions and 27 deletions

View File

@ -28,4 +28,6 @@ import Foundation
public protocol AppReceiptReader {
func receipt(at userLevel: AppUserLevel) async -> InAppReceipt?
func addPurchase(with identifier: String) async
}

View File

@ -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(

View File

@ -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)")
}
}

View File

@ -53,6 +53,10 @@ public actor FallbackReceiptReader: AppReceiptReader {
pendingTask = nil
return receipt
}
public func addPurchase(with identifier: String) async {
//
}
}
private extension FallbackReceiptReader {

View File

@ -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
}

View File

@ -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

View File

@ -36,6 +36,7 @@ extension AppContext {
let iapManager = IAPManager(
inAppHelper: FakeAppProductHelper(),
receiptReader: FakeAppReceiptReader(),
betaChecker: TestFlightChecker(),
unrestrictedFeatures: [
.interactiveLogin,
.onDemand

View File

@ -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
)

View File

@ -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
}
}