Skip to content

Commit

Permalink
New TestStoreProduct for creating mock StoreProducts and `Offerin…
Browse files Browse the repository at this point in the history
…g`s (#2711)

Fixes #1563 and #2193.

This introduces **`DEBUG` and `Swift` only** types that allow creating a
whole `Offering` for tests and SwiftUI previews.

Example:
![Screenshot 2023-06-26 at 12 36
40](https://github.com/RevenueCat/purchases-ios/assets/685609/03d83e65-b95b-40ba-80ad-f8be435c6d3e)

By making it available in only `DEBUG` builds we ensure that apps can't
accidentally try to purchase these products, which would be impossible.
If this is attempted in `DEBUG` builds, we return `ErrorCode`.

When trying to compile this API in release builds, you get an error with
a suggestion:

![image](https://github.com/RevenueCat/purchases-ios/assets/685609/f83b8848-dab6-4ae6-a92d-5c07a30be217)
  • Loading branch information
NachoSoto authored Jun 29, 2023
1 parent 4a154fc commit 761ca12
Show file tree
Hide file tree
Showing 18 changed files with 651 additions and 29 deletions.
99 changes: 86 additions & 13 deletions Examples/MagicWeatherSwiftUI/Shared/Sources/Views/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ struct PaywallView: View {
/// - This binding is passed from ContentView: `paywallPresented`
@Binding var isPresented: Bool

/// - State for displaying an overlay view
@State
private(set) var isPurchasing: Bool = false

/// - This can change during the lifetime of the PaywallView (e.g. if poor network conditions mean that loading offerings is slow)
/// So set this as an observed object to trigger view updates as necessary
@ObservedObject var userViewModel = UserViewModel.shared
Expand All @@ -30,9 +26,20 @@ struct PaywallView: View {
private var offering: Offering? {
userViewModel.offerings?.current
}

private let footerText = "Don't forget to add your subscription terms and conditions. Read more about this here: https://www.revenuecat.com/blog/schedule-2-section-3-8-b"


var body: some View {
PaywallContent(offering: self.offering, isPresented: self.$isPresented)
}

}

private struct PaywallContent: View {

var offering: Offering?
var isPresented: Binding<Bool>

/// - State for displaying an overlay view
@State private var isPurchasing: Bool = false
@State private var error: NSError?
@State private var displayError: Bool = false

Expand All @@ -41,13 +48,13 @@ struct PaywallView: View {
ZStack {
/// - The paywall view list displaying each package
List {
Section(header: Text("\nMagic Weather Premium"), footer: Text(footerText)) {
Section(header: Text("\nMagic Weather Premium"), footer: Text(Self.footerText)) {
ForEach(offering?.availablePackages ?? []) { package in
PackageCellView(package: package) { (package) in

/// - Set 'isPurchasing' state to `true`
isPurchasing = true

/// - Purchase a package
do {
let result = try await Purchases.shared.purchase(package: package)
Expand All @@ -56,7 +63,7 @@ struct PaywallView: View {
self.isPurchasing = false

if !result.userCancelled {
self.isPresented = false
self.isPresented.wrappedValue = false
}
} catch {
self.isPurchasing = false
Expand All @@ -72,7 +79,7 @@ struct PaywallView: View {
.navigationBarTitleDisplayMode(.inline)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.edgesIgnoringSafeArea(.bottom)

/// - Display an overlay during a purchase
Rectangle()
.foregroundColor(Color.black)
Expand All @@ -93,10 +100,13 @@ struct PaywallView: View {
message: { Text($0.recoverySuggestion ?? "Please try again") }
)
}

private static let footerText = "Don't forget to add your subscription terms and conditions. Read more about this here: https://www.revenuecat.com/blog/schedule-2-section-3-8-b"

}

/* The cell view for each package */
struct PackageCellView: View {
private struct PackageCellView: View {

let package: Package
let onSelection: (Package) async -> Void
Expand Down Expand Up @@ -149,3 +159,66 @@ extension NSError: LocalizedError {
}

}

struct PaywallView_Previews: PreviewProvider {

private static let product1 = TestStoreProduct(
localizedTitle: "PRO monthly",
price: 3.99,
localizedPriceString: "$3.99",
productIdentifier: "com.revenuecat.product",
productType: .autoRenewableSubscription,
localizedDescription: "Description",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .month),
introductoryDiscount: .init(
identifier: "intro",
price: 0,
localizedPriceString: "$0.00",
paymentMode: .freeTrial,
subscriptionPeriod: .init(value: 1, unit: .week),
numberOfPeriods: 1,
type: .introductory
),
discounts: []
)
private static let product2 = TestStoreProduct(
localizedTitle: "PRO annual",
price: 34.99,
localizedPriceString: "$34.99",
productIdentifier: "com.revenuecat.product",
productType: .autoRenewableSubscription,
localizedDescription: "Description",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .year),
introductoryDiscount: nil,
discounts: []
)

private static let offering = Offering(
identifier: Self.offeringIdentifier,
serverDescription: "Main offering",
metadata: [:],
availablePackages: [
.init(
identifier: "monthly",
packageType: .monthly,
storeProduct: product1.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
),
.init(
identifier: "annual",
packageType: .annual,
storeProduct: product2.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
]
)

private static let offeringIdentifier = "offering"

static var previews: some View {
PaywallContent(offering: Self.offering, isPresented: .constant(true))
}

}
16 changes: 16 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@
4F0BBA812A1D0524000E75AB /* DefaultDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */; };
4F0BBAAC2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */; };
4F0CE2BD2A215CE600561895 /* TransactionPosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */; };
4F1428A42A4A132C006CD196 /* TestStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1428A32A4A132C006CD196 /* TestStoreProductDiscount.swift */; };
4F2017D52A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2017D42A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift */; };
4F2018732A15797D0061F6EF /* TestLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57057FF728B0048900995F21 /* TestLogHandler.swift */; };
4F2F2EFF2A3CDAA800652B24 /* FileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2F2EFE2A3CDAA800652B24 /* FileHandler.swift */; };
Expand Down Expand Up @@ -244,6 +245,7 @@
4F8A58182A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */; };
4F90AFCB2A3915340047E63F /* TestMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F90AFCA2A3915340047E63F /* TestMessage.swift */; };
4F90AFCC2A3915BC0047E63F /* TestMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F90AFCA2A3915340047E63F /* TestMessage.swift */; };
4F98E9D32A465A4400DB6EAB /* TestStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F98E9D22A465A4400DB6EAB /* TestStoreProduct.swift */; };
4FA4C8DA2A168956007D2803 /* OfflineCustomerInfoCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA4C8D92A168956007D2803 /* OfflineCustomerInfoCreator.swift */; };
4FA4C9732A16D3AC007D2803 /* MockBackendConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA4C9722A16D3AC007D2803 /* MockBackendConfiguration.swift */; };
4FA4C9742A16D3AC007D2803 /* MockBackendConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA4C9722A16D3AC007D2803 /* MockBackendConfiguration.swift */; };
Expand Down Expand Up @@ -929,6 +931,7 @@
4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDecodable.swift; sourceTree = "<group>"; };
4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCustomerInfoCreatorTests.swift; sourceTree = "<group>"; };
4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionPosterTests.swift; sourceTree = "<group>"; };
4F1428A32A4A132C006CD196 /* TestStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStoreProductDiscount.swift; sourceTree = "<group>"; };
4F2017D42A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineStoreKitIntegrationTests.swift; sourceTree = "<group>"; };
4F2F2EFE2A3CDAA800652B24 /* FileHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileHandler.swift; sourceTree = "<group>"; };
4F2F2F132A3CEAB500652B24 /* FileHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileHandlerTests.swift; sourceTree = "<group>"; };
Expand All @@ -951,6 +954,7 @@
4F8038322A1EA7C300D21039 /* TransactionPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionPoster.swift; sourceTree = "<group>"; };
4F8A58162A16EE3500EF97AD /* MockOfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOfflineCustomerInfoCreator.swift; sourceTree = "<group>"; };
4F90AFCA2A3915340047E63F /* TestMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMessage.swift; sourceTree = "<group>"; };
4F98E9D22A465A4400DB6EAB /* TestStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStoreProduct.swift; sourceTree = "<group>"; };
4FA4C8D92A168956007D2803 /* OfflineCustomerInfoCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCustomerInfoCreator.swift; sourceTree = "<group>"; };
4FA4C9722A16D3AC007D2803 /* MockBackendConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBackendConfiguration.swift; sourceTree = "<group>"; };
4FA696A329FC43C600D228B1 /* ReceiptParserTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "ReceiptParserTests-Info.plist"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1428,6 +1432,7 @@
2D1015DC275A57DB0086173F /* StoreKitAbstractions */ = {
isa = PBXGroup;
children = (
4F1428A52A4A1330006CD196 /* Test Data */,
57EFDC6A27BC1F370057EC39 /* ProductType.swift */,
2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */,
57DE807028074C23008D6C6F /* SK1Storefront.swift */,
Expand Down Expand Up @@ -2144,6 +2149,15 @@
path = BasicTypes;
sourceTree = "<group>";
};
4F1428A52A4A1330006CD196 /* Test Data */ = {
isa = PBXGroup;
children = (
4F98E9D22A465A4400DB6EAB /* TestStoreProduct.swift */,
4F1428A32A4A132C006CD196 /* TestStoreProductDiscount.swift */,
);
path = "Test Data";
sourceTree = "<group>";
};
4F2F2EFD2A3CDA9B00652B24 /* Diagnostics */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3263,6 +3277,7 @@
4FCEEA5E2A379B80002C2112 /* DebugViewController.swift in Sources */,
579415D529368AB200218FBC /* ReceiptStrings.swift in Sources */,
57A0FBF02749C0C2009E2FC3 /* Atomic.swift in Sources */,
4F98E9D32A465A4400DB6EAB /* TestStoreProduct.swift in Sources */,
2DC5623224EC63730031F69B /* TransactionsFactory.swift in Sources */,
579415D2293689DD00218FBC /* Codable+Extensions.swift in Sources */,
2DDF41B424F6F387005BC22D /* ASN1ContainerBuilder.swift in Sources */,
Expand Down Expand Up @@ -3334,6 +3349,7 @@
57D5414227F656D9004CC35C /* NetworkError.swift in Sources */,
B3781568285A79FC000A7B93 /* IdentityAPI.swift in Sources */,
B325543C2825C81800DA62EA /* Configuration.swift in Sources */,
4F1428A42A4A132C006CD196 /* TestStoreProductDiscount.swift in Sources */,
F5BE447B269E4A7500254A30 /* TrackingManagerProxy.swift in Sources */,
5746508C27586B2E0053AB09 /* Result+Extensions.swift in Sources */,
B34D2AA0269606E400D88C3A /* IntroEligibility.swift in Sources */,
Expand Down
13 changes: 11 additions & 2 deletions Sources/Purchasing/Offering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,15 @@ import Foundation
return package(identifier: key)
}

// swiftlint:disable:next cyclomatic_complexity
init(identifier: String, serverDescription: String, metadata: [String: Any], availablePackages: [Package]) {
// swiftlint:disable cyclomatic_complexity

/// Initialize an ``Offering`` given a list of ``Package``s.
public init(
identifier: String,
serverDescription: String,
metadata: [String: Any],
availablePackages: [Package]
) {
self.identifier = identifier
self.serverDescription = serverDescription
self.availablePackages = availablePackages
Expand Down Expand Up @@ -171,6 +178,8 @@ import Foundation
super.init()
}

// swiftlint:enable cyclomatic_complexity

}

extension Offering {
Expand Down
12 changes: 10 additions & 2 deletions Sources/Purchasing/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,19 @@ import Foundation
return self.storeProduct.localizedIntroductoryPriceString
}

init(identifier: String, packageType: PackageType, storeProduct: StoreProductType, offeringIdentifier: String) {
/// Initialize a ``Package``.
public init(
identifier: String,
packageType: PackageType,
storeProduct: StoreProduct,
offeringIdentifier: String
) {
self.identifier = identifier
self.packageType = packageType
self.storeProduct = StoreProduct.from(product: storeProduct)
self.storeProduct = storeProduct
self.offeringIdentifier = offeringIdentifier

super.init()
}

public override func isEqual(_ object: Any?) -> Bool {
Expand Down
15 changes: 15 additions & 0 deletions Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ final class PurchasesOrchestrator {
package: package,
promotionalOffer: nil,
completion: completion)
} else if product.isTestProduct {
self.handleTestProduct(completion)
} else {
fatalError("Unrecognized product: \(product)")
}
Expand All @@ -315,6 +317,8 @@ final class PurchasesOrchestrator {
package: package,
promotionalOffer: promotionalOffer,
completion: completion)
} else if product.isTestProduct {
self.handleTestProduct(completion)
} else {
fatalError("Unrecognized product: \(product)")
}
Expand Down Expand Up @@ -1149,6 +1153,17 @@ private extension PurchasesOrchestrator {
}
}

func handleTestProduct(_ completion: @escaping PurchaseCompletedBlock) {
self.operationDispatcher.dispatchOnMainActor {
completion(
nil,
nil,
ErrorUtils.productNotAvailableForPurchaseError().asPublicError,
false
)
}
}

}

private extension PurchasesOrchestrator {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Purchasing/StoreKitAbstractions/ProductType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ extension StoreProduct.ProductType {
}

}

extension StoreProduct.ProductCategory: Sendable {}
extension StoreProduct.ProductType: Sendable {}
14 changes: 11 additions & 3 deletions Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,11 @@ internal protocol StoreProductType: Sendable {
/// For a string representation of the price to display to customers, use ``localizedPriceString``.
///
/// #### Related Symbols
/// - ``pricePerMonth``
/// - ``pricePerYear``
/// - ``StoreProduct/pricePerMonth``
/// - ``StoreProduct/pricePerYear``
var price: Decimal { get }

/// The price of this product using ``priceFormatter``.
/// The price of this product using ``StoreProduct/priceFormatter``.
var localizedPriceString: String { get }

/// The string that identifies the product to the Apple App Store.
Expand Down Expand Up @@ -277,6 +277,14 @@ extension StoreProduct {
return (self.product as? SK2StoreProduct)?.underlyingSK2Product
}

var isTestProduct: Bool {
#if DEBUG
return self.product is TestStoreProduct
#else
return false
#endif
}

}

// MARK: - Renames
Expand Down
Loading

0 comments on commit 761ca12

Please sign in to comment.