From 24d66605ea71b48b06318e1c15085442ff8be616 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega Date: Wed, 11 Dec 2024 20:55:22 +0100 Subject: [PATCH] Refactors the creation of the subscription details in Customer Center (#4515) --- RevenueCat.xcodeproj/project.pbxproj | 8 +- .../Data/CustomerCenterConfigTestData.swift | 4 - .../Data/PurchaseInformation.swift | 111 ++-- .../ViewModels/CustomerCenterViewModel.swift | 97 ++- .../ManageSubscriptionsViewModel.swift | 46 +- .../Views/CustomerCenterView.swift | 28 +- .../Views/ManageSubscriptionsView.swift | 91 ++- .../Views/WrongPlatformView.swift | 56 +- RevenueCatUI/Data/CustomerInfoFixtures.swift | 9 +- .../CustomerCenterViewModelTests.swift | 559 +++++++++++++++++- .../ManageSubscriptionsViewModelTests.swift | 506 +--------------- .../MockCustomerCenterPurchases.swift | 46 +- ...s.swift => PurchaseInformationTests.swift} | 174 +++++- 13 files changed, 951 insertions(+), 784 deletions(-) rename Tests/RevenueCatUITests/CustomerCenter/{SubscriptionInformationTests.swift => PurchaseInformationTests.swift} (70%) diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index e36d18cfbb..ed9ef9c5c4 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -291,7 +291,7 @@ 3592E8902C2ED5C100D7F91D /* CustomerCenterConfigAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3592E88F2C2ED5C100D7F91D /* CustomerCenterConfigAPI.swift */; }; 359E8E3F26DEBEEB00B869F9 /* TrialOrIntroPriceEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359E8E3E26DEBEEB00B869F9 /* TrialOrIntroPriceEligibilityChecker.swift */; }; 35A99C832CCB95950074AB41 /* SubscriptionInformationFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A99C822CCB95950074AB41 /* SubscriptionInformationFixtures.swift */; }; - 35A99C842CCB95A70074AB41 /* SubscriptionInformationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A99C802CCB8D530074AB41 /* SubscriptionInformationTests.swift */; }; + 35A99C842CCB95A70074AB41 /* PurchaseInformationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A99C802CCB8D530074AB41 /* PurchaseInformationTests.swift */; }; 35AAEB452BBB14D000A12548 /* DiagnosticsFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAEB442BBB14D000A12548 /* DiagnosticsFileHandler.swift */; }; 35AAEB492BBB17B500A12548 /* DiagnosticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAEB482BBB17B500A12548 /* DiagnosticsEvent.swift */; }; 35AAEB4C2BBC39D100A12548 /* DiagnosticsFileHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAEB4A2BBC380600A12548 /* DiagnosticsFileHandlerTests.swift */; }; @@ -1566,7 +1566,7 @@ 3597020F24BF6A710010506E /* TransactionsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsFactory.swift; sourceTree = ""; }; 3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsFactoryTests.swift; sourceTree = ""; }; 359E8E3E26DEBEEB00B869F9 /* TrialOrIntroPriceEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrialOrIntroPriceEligibilityChecker.swift; sourceTree = ""; }; - 35A99C802CCB8D530074AB41 /* SubscriptionInformationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInformationTests.swift; sourceTree = ""; }; + 35A99C802CCB8D530074AB41 /* PurchaseInformationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseInformationTests.swift; sourceTree = ""; }; 35A99C822CCB95950074AB41 /* SubscriptionInformationFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInformationFixtures.swift; sourceTree = ""; }; 35AAEB442BBB14D000A12548 /* DiagnosticsFileHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsFileHandler.swift; sourceTree = ""; }; 35AAEB482BBB17B500A12548 /* DiagnosticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsEvent.swift; sourceTree = ""; }; @@ -3681,7 +3681,7 @@ 3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */, 3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */, 1E2F911A2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift */, - 35A99C802CCB8D530074AB41 /* SubscriptionInformationTests.swift */, + 35A99C802CCB8D530074AB41 /* PurchaseInformationTests.swift */, ); path = CustomerCenter; sourceTree = ""; @@ -6661,7 +6661,7 @@ 35A99C832CCB95950074AB41 /* SubscriptionInformationFixtures.swift in Sources */, 887A633B2C1D177800E1A461 /* PaywallViewLocalizationTests.swift in Sources */, 887A633C2C1D177800E1A461 /* Template1ViewTests.swift in Sources */, - 35A99C842CCB95A70074AB41 /* SubscriptionInformationTests.swift in Sources */, + 35A99C842CCB95A70074AB41 /* PurchaseInformationTests.swift in Sources */, 777FB4882C661C0600CD4749 /* SemanticVersionTests.swift in Sources */, 88AD4C482C24E8EA00943C3E /* ExternalPurchaseAndRestoreTests.swift in Sources */, 887A633D2C1D177800E1A461 /* Template2ViewTests.swift in Sources */, diff --git a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift index c41497862f..c317cb47ad 100644 --- a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift +++ b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift @@ -135,9 +135,7 @@ enum CustomerCenterConfigTestData { price: .paid("$4.99"), expirationOrRenewal: .init(label: .nextBillingDate, date: .date("June 1st, 2024")), - willRenew: true, productIdentifier: "product_id", - active: true, store: .appStore ) @@ -148,9 +146,7 @@ enum CustomerCenterConfigTestData { price: .paid("$49.99"), expirationOrRenewal: .init(label: .expires, date: .date("June 1st, 2024")), - willRenew: false, productIdentifier: "product_id", - active: true, store: .appStore ) diff --git a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift index 48f360aaa8..fc8144216c 100644 --- a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift +++ b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift @@ -32,9 +32,7 @@ struct PurchaseInformation { explanation: Explanation, price: PriceDetails, expirationOrRenewal: ExpirationOrRenewal?, - willRenew: Bool, productIdentifier: String, - active: Bool, store: Store ) { self.title = title @@ -46,60 +44,46 @@ struct PurchaseInformation { self.store = store } - init(explanation: Explanation, - price: PriceDetails, - expirationOrRenewal: ExpirationOrRenewal?, - willRenew: Bool, - productIdentifier: String, - active: Bool, - store: Store - ) { - self.title = nil - self.durationTitle = nil - self.explanation = explanation - self.price = price - self.expirationOrRenewal = expirationOrRenewal - self.productIdentifier = productIdentifier - self.store = store - } - - init(entitlement: EntitlementInfo, + init(entitlement: EntitlementInfo? = nil, subscribedProduct: StoreProduct? = nil, + transaction: Transaction, dateFormatter: DateFormatter = DateFormatter()) { dateFormatter.dateStyle = .medium + // Title and duration from product if available self.title = subscribedProduct?.localizedTitle - self.explanation = entitlement.explanation self.durationTitle = subscribedProduct?.subscriptionPeriod?.durationTitle - self.price = entitlement.priceBestEffort(product: subscribedProduct) - self.expirationOrRenewal = entitlement.expirationOrRenewal(dateFormatter: dateFormatter) - self.productIdentifier = entitlement.productIdentifier - self.store = entitlement.store - } - init(product: StoreProduct, - expirationDate: Date?, - dateFormatter: DateFormatter = DateFormatter()) { - // We don't have enough information to determine if the subscription will renew or not because we - // are loading the information from the product without entitlement information. - // We also assume that the subscription is active. - // We also assume that the subscription will renew the earliest possible renewal date and this is the - // product with the earliest renewal date. - dateFormatter.dateStyle = .medium - - self.title = product.localizedTitle - self.explanation = .earliestRenewal - self.durationTitle = product.subscriptionPeriod?.durationTitle - self.price = .paid(product.localizedPriceString) - if let dateString = expirationDate.map({ dateFormatter.string(from: $0) }) { - let date = PurchaseInformation.ExpirationOrRenewal.Date.date(dateString) - self.expirationOrRenewal = PurchaseInformation.ExpirationOrRenewal(label: .expires, - date: date) + // Use entitlement data if available, otherwise derive from transaction + if let entitlement = entitlement { + self.explanation = entitlement.explanation + self.expirationOrRenewal = entitlement.expirationOrRenewal(dateFormatter: dateFormatter) + self.productIdentifier = entitlement.productIdentifier + self.store = entitlement.store + self.price = entitlement.priceBestEffort(product: subscribedProduct) } else { - self.expirationOrRenewal = nil + switch transaction.type { + case .subscription(let isActive, let willRenew, let expiresDate): + self.explanation = expiresDate != nil + ? (isActive ? (willRenew ? .earliestRenewal : .earliestExpiration) : .expired) + : .lifetime + self.expirationOrRenewal = expiresDate.map { date in + let dateString = dateFormatter.string(from: date) + let label: ExpirationOrRenewal.Label = isActive + ? (willRenew ? .nextBillingDate : .expires) + : .expired + return ExpirationOrRenewal(label: label, date: .date(dateString)) + } + case .nonSubscription: + self.explanation = .lifetime + self.expirationOrRenewal = nil + } + + self.productIdentifier = transaction.productIdentifier + self.store = transaction.store + self.price = transaction.store == .promotional ? .free + : (subscribedProduct.map { .paid($0.localizedPriceString) } ?? .unknown) } - self.productIdentifier = product.productIdentifier - self.store = .appStore } struct ExpirationOrRenewal { @@ -236,3 +220,36 @@ fileprivate extension String { } } + +protocol Transaction { + + var productIdentifier: String { get } + var store: Store { get } + var type: TransactionType { get } + +} + +enum TransactionType { + + case subscription(isActive: Bool, willRenew: Bool, expiresDate: Date?) + case nonSubscription + +} + +extension SubscriptionInfo: Transaction { + + var type: TransactionType { + .subscription(isActive: isActive, + willRenew: willRenew, + expiresDate: expiresDate) + } + +} + +extension NonSubscriptionTransaction: Transaction { + + var type: TransactionType { + .nonSubscription + } + +} diff --git a/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift index 461568874f..3a9a8829d8 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift @@ -23,17 +23,14 @@ import RevenueCat @available(tvOS, unavailable) @available(watchOS, unavailable) @MainActor class CustomerCenterViewModel: ObservableObject { - // We fail open. + private static let defaultAppIsLatestVersion = true typealias CurrentVersionFetcher = () -> String? private lazy var currentAppVersion: String? = currentVersionFetcher() - - @Published - private(set) var hasActiveProducts: Bool = false @Published - private(set) var hasAppleEntitlement: Bool = false + private(set) var purchaseInformation: PurchaseInformation? @Published private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion private(set) var purchasesProvider: CustomerCenterPurchasesType @@ -61,7 +58,7 @@ import RevenueCat } self.appIsLatestVersion = currentVersion >= latestVersion - } + } } var isLoaded: Bool { @@ -89,25 +86,41 @@ import RevenueCat #if DEBUG convenience init( - hasActiveProducts: Bool = false, - hasAppleEntitlement: Bool = false + purchaseInformation: PurchaseInformation, + configuration: CustomerCenterConfigData ) { self.init(customerCenterActionHandler: nil) - self.hasActiveProducts = hasActiveProducts - self.hasAppleEntitlement = hasAppleEntitlement + self.purchaseInformation = purchaseInformation + self.configuration = configuration self.state = .success } #endif - func loadHasActivePurchases() async { + func loadPurchaseInformation() async { do { let customerInfo = try await purchasesProvider.customerInfo() - self.hasActiveProducts = customerInfo.activeSubscriptions.count > 0 || - customerInfo.nonSubscriptions.count > 0 - self.hasAppleEntitlement = customerInfo.entitlements.active.contains { entitlement in - entitlement.value.store == .appStore + let hasActiveProducts = + !customerInfo.activeSubscriptions.isEmpty || !customerInfo.nonSubscriptions.isEmpty + + if !hasActiveProducts { + self.purchaseInformation = nil + self.state = .success + return } + + guard let activeTransaction = findActiveTransaction(customerInfo: customerInfo) else { + Logger.warning(Strings.could_not_find_subscription_information) + self.purchaseInformation = nil + throw CustomerCenterError.couldNotFindSubscriptionInformation + } + + let entitlement = customerInfo.entitlements.all.values + .first(where: { $0.productIdentifier == activeTransaction.productIdentifier }) + + self.purchaseInformation = try await createPurchaseInformation(for: activeTransaction, + entitlement: entitlement) + self.state = .success } catch { self.state = .error(error) @@ -148,6 +161,60 @@ import RevenueCat } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@available(macOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +private extension CustomerCenterViewModel { + + func findActiveTransaction(customerInfo: CustomerInfo) -> Transaction? { + let activeSubscriptions = customerInfo.subscriptionsByProductIdentifier.values + .filter(\.isActive) + .sorted(by: { + guard let date1 = $0.expiresDate, let date2 = $1.expiresDate else { + return $0.expiresDate != nil + } + return date1 < date2 + }) + + let (activeAppleSubscriptions, otherActiveSubscriptions) = ( + activeSubscriptions.filter { $0.store == .appStore }, + activeSubscriptions.filter { $0.store != .appStore } + ) + + let (appleNonSubscriptions, otherNonSubscriptions) = ( + customerInfo.nonSubscriptions.filter { $0.store == .appStore }, + customerInfo.nonSubscriptions.filter { $0.store != .appStore } + ) + + return activeAppleSubscriptions.first ?? + appleNonSubscriptions.first ?? + otherActiveSubscriptions.first ?? + otherNonSubscriptions.first + } + + func createPurchaseInformation(for transaction: Transaction, + entitlement: EntitlementInfo?) async throws -> PurchaseInformation { + if transaction.store == .appStore { + guard let product = await purchasesProvider.products([transaction.productIdentifier]).first else { + Logger.warning(Strings.could_not_find_subscription_information) + throw CustomerCenterError.couldNotFindSubscriptionInformation + } + return PurchaseInformation( + entitlement: entitlement, + subscribedProduct: product, + transaction: transaction + ) + } else { + return PurchaseInformation( + entitlement: entitlement, + transaction: transaction + ) + } + } + +} + fileprivate extension String { /// Takes the first characters of this string, if they conform to Major.Minor.Patch. Returns nil otherwise. /// Note that Minor and Patch are optional. So if this string starts with a single number, that number is returned. diff --git a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift index 43b60f7535..a0cc590c9f 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift @@ -48,10 +48,6 @@ class ManageSubscriptionsViewModel: ObservableObject { } } - var isLoaded: Bool { - return state != .notLoaded - } - @Published private(set) var purchaseInformation: PurchaseInformation? @Published @@ -64,52 +60,18 @@ class ManageSubscriptionsViewModel: ObservableObject { init(screen: CustomerCenterConfigData.Screen, customerCenterActionHandler: CustomerCenterActionHandler?, + purchaseInformation: PurchaseInformation? = nil, + refundRequestStatus: RefundRequestStatus? = nil, purchasesProvider: ManageSubscriptionsPurchaseType = ManageSubscriptionPurchases(), loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType? = nil) { self.screen = screen self.paths = screen.filteredPaths - self.purchasesProvider = purchasesProvider - self.customerCenterActionHandler = customerCenterActionHandler - self.loadPromotionalOfferUseCase = loadPromotionalOfferUseCase ?? LoadPromotionalOfferUseCase() - self.state = .notLoaded - } - - init(screen: CustomerCenterConfigData.Screen, - purchaseInformation: PurchaseInformation, - customerCenterActionHandler: CustomerCenterActionHandler?, - refundRequestStatus: RefundRequestStatus? = nil) { - self.screen = screen - self.paths = screen.filteredPaths self.purchaseInformation = purchaseInformation self.purchasesProvider = ManageSubscriptionPurchases() self.refundRequestStatus = refundRequestStatus self.customerCenterActionHandler = customerCenterActionHandler - self.loadPromotionalOfferUseCase = LoadPromotionalOfferUseCase() - state = .success - } - - func loadScreen() async { - do { - try await loadPurchaseInformation() - self.state = .success - } catch { - self.state = .error(error) - } - } - - private func loadPurchaseInformation() async throws { - let customerInfo = try await purchasesProvider.customerInfo() - - guard let currentEntitlement = customerInfo.earliestExpiringAppStoreEntitlement(), - let product = await purchasesProvider.products([currentEntitlement.productIdentifier]).first - else { - Logger.warning(Strings.could_not_find_subscription_information) - throw CustomerCenterError.couldNotFindSubscriptionInformation - } - - let purchaseInformation = PurchaseInformation(entitlement: currentEntitlement, - subscribedProduct: product) - self.purchaseInformation = purchaseInformation + self.loadPromotionalOfferUseCase = loadPromotionalOfferUseCase ?? LoadPromotionalOfferUseCase() + self.state = .success } #if os(iOS) || targetEnvironment(macCatalyst) diff --git a/RevenueCatUI/CustomerCenter/Views/CustomerCenterView.swift b/RevenueCatUI/CustomerCenter/Views/CustomerCenterView.swift index 64d07d8442..c67fc2eed4 100644 --- a/RevenueCatUI/CustomerCenter/Views/CustomerCenterView.swift +++ b/RevenueCatUI/CustomerCenter/Views/CustomerCenterView.swift @@ -87,6 +87,9 @@ public struct CustomerCenterView: View { await loadInformationIfNeeded() } .task { +#if DEBUG + guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return } +#endif self.trackImpression() } .environmentObject(self.viewModel) @@ -102,15 +105,15 @@ private extension CustomerCenterView { func loadInformationIfNeeded() async { if !viewModel.isLoaded { - await viewModel.loadHasActivePurchases() + await viewModel.loadPurchaseInformation() await viewModel.loadCustomerCenterConfig() } } @ViewBuilder func destinationContent(configuration: CustomerCenterConfigData) -> some View { - if viewModel.hasActiveProducts { - if viewModel.hasAppleEntitlement, + if let purchaseInformation = viewModel.purchaseInformation { + if purchaseInformation.store == .appStore, let screen = configuration.screens[.management] { if let productId = configuration.productId, !ignoreAppUpdateWarning && !viewModel.appIsLatestVersion { AppUpdateWarningView( @@ -123,16 +126,19 @@ private extension CustomerCenterView { ) } else { ManageSubscriptionsView(screen: screen, + purchaseInformation: purchaseInformation, customerCenterActionHandler: viewModel.customerCenterActionHandler) } } else if let screen = configuration.screens[.management] { - WrongPlatformView(screen: screen) + WrongPlatformView(screen: screen, + purchaseInformation: purchaseInformation) } else { - WrongPlatformView() + WrongPlatformView(purchaseInformation: purchaseInformation) } } else { if let screen = configuration.screens[.noActive] { ManageSubscriptionsView(screen: screen, + purchaseInformation: nil, customerCenterActionHandler: viewModel.customerCenterActionHandler) } else { // Fallback with a restore button @@ -167,10 +173,14 @@ private extension CustomerCenterView { @available(watchOS, unavailable) struct CustomerCenterView_Previews: PreviewProvider { - static var previews: some View { - let viewModel = CustomerCenterViewModel(hasActiveProducts: false, hasAppleEntitlement: false) - CustomerCenterView(viewModel: viewModel) - } + static var previews: some View { + let purchaseInformationApple = + CustomerCenterConfigTestData.subscriptionInformationMonthlyRenewing + let viewModelApple = CustomerCenterViewModel(purchaseInformation: purchaseInformationApple, + configuration: CustomerCenterConfigTestData.customerCenterData) + CustomerCenterView(viewModel: viewModelApple) + .previewDisplayName("Monthly Apple") + } } diff --git a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift index bf8ffe6ebe..12e4690623 100644 --- a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift +++ b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift @@ -37,9 +37,12 @@ struct ManageSubscriptionsView: View { private let customerCenterActionHandler: CustomerCenterActionHandler? init(screen: CustomerCenterConfigData.Screen, + purchaseInformation: PurchaseInformation?, customerCenterActionHandler: CustomerCenterActionHandler?) { - let viewModel = ManageSubscriptionsViewModel(screen: screen, - customerCenterActionHandler: customerCenterActionHandler) + let viewModel = ManageSubscriptionsViewModel( + screen: screen, + customerCenterActionHandler: customerCenterActionHandler, + purchaseInformation: purchaseInformation) self.init(viewModel: viewModel, customerCenterActionHandler: customerCenterActionHandler) } @@ -77,43 +80,40 @@ struct ManageSubscriptionsView: View { @ViewBuilder var content: some View { ZStack { - if self.viewModel.isLoaded { - List { + List { - if let purchaseInformation = self.viewModel.purchaseInformation { - Section { - SubscriptionDetailsView(purchaseInformation: purchaseInformation, - refundRequestStatus: self.viewModel.refundRequestStatus) - } - Section { - ManageSubscriptionsButtonsView(viewModel: self.viewModel, - loadingPath: self.$viewModel.loadingPath) - } header: { - if let subtitle = self.viewModel.screen.subtitle { - Text(subtitle) - .textCase(nil) - } - } - } else { - let fallbackDescription = localization.commonLocalizedString(for: .tryCheckRestore) - - Section { - CompatibilityContentUnavailableView( - self.viewModel.screen.title, - systemImage: "exclamationmark.triangle.fill", - description: Text(self.viewModel.screen.subtitle ?? fallbackDescription) - ) - } - - Section { - ManageSubscriptionsButtonsView(viewModel: self.viewModel, - loadingPath: self.$viewModel.loadingPath) + if let purchaseInformation = self.viewModel.purchaseInformation { + Section { + SubscriptionDetailsView( + purchaseInformation: purchaseInformation, + refundRequestStatus: self.viewModel.refundRequestStatus) + } + Section { + ManageSubscriptionsButtonsView(viewModel: self.viewModel, + loadingPath: self.$viewModel.loadingPath) + } header: { + if let subtitle = self.viewModel.screen.subtitle { + Text(subtitle) + .textCase(nil) } } + } else { + let fallbackDescription = localization.commonLocalizedString(for: .tryCheckRestore) + + Section { + CompatibilityContentUnavailableView( + self.viewModel.screen.title, + systemImage: "exclamationmark.triangle.fill", + description: Text(self.viewModel.screen.subtitle ?? fallbackDescription) + ) + } + Section { + ManageSubscriptionsButtonsView(viewModel: self.viewModel, + loadingPath: self.$viewModel.loadingPath) + } } - } else { - TintedProgressView() + } } .toolbar { @@ -121,9 +121,6 @@ struct ManageSubscriptionsView: View { DismissCircleButton() } } - .task { - await loadInformationIfNeeded() - } .restorePurchasesAlert(isPresented: self.$viewModel.showRestoreAlert) .sheet( item: self.$viewModel.promotionalOfferData, @@ -154,20 +151,6 @@ struct ManageSubscriptionsView: View { } -@available(iOS 15.0, *) -@available(macOS, unavailable) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -private extension ManageSubscriptionsView { - - func loadInformationIfNeeded() async { - if !self.viewModel.isLoaded { - await viewModel.loadScreen() - } - } - -} - #if DEBUG @available(iOS 15.0, *) @available(macOS, unavailable) @@ -180,8 +163,8 @@ struct ManageSubscriptionsView_Previews: PreviewProvider { CompatibilityNavigationStack { let viewModelMonthlyRenewing = ManageSubscriptionsViewModel( screen: CustomerCenterConfigTestData.customerCenterData.screens[.management]!, - purchaseInformation: CustomerCenterConfigTestData.subscriptionInformationMonthlyRenewing, customerCenterActionHandler: nil, + purchaseInformation: CustomerCenterConfigTestData.subscriptionInformationMonthlyRenewing, refundRequestStatus: .success) ManageSubscriptionsView(viewModel: viewModelMonthlyRenewing, customerCenterActionHandler: nil) @@ -193,8 +176,8 @@ struct ManageSubscriptionsView_Previews: PreviewProvider { CompatibilityNavigationStack { let viewModelYearlyExpiring = ManageSubscriptionsViewModel( screen: CustomerCenterConfigTestData.customerCenterData.screens[.management]!, - purchaseInformation: CustomerCenterConfigTestData.subscriptionInformationYearlyExpiring, - customerCenterActionHandler: nil) + customerCenterActionHandler: nil, + purchaseInformation: CustomerCenterConfigTestData.subscriptionInformationYearlyExpiring) ManageSubscriptionsView(viewModel: viewModelYearlyExpiring, customerCenterActionHandler: nil) .environment(\.localization, CustomerCenterConfigTestData.customerCenterData.localization) diff --git a/RevenueCatUI/CustomerCenter/Views/WrongPlatformView.swift b/RevenueCatUI/CustomerCenter/Views/WrongPlatformView.swift index 2747465aa5..38780eaa91 100644 --- a/RevenueCatUI/CustomerCenter/Views/WrongPlatformView.swift +++ b/RevenueCatUI/CustomerCenter/Views/WrongPlatformView.swift @@ -30,7 +30,7 @@ struct WrongPlatformView: View { @State private var managementURL: URL? @State - private var subscriptionInformation: PurchaseInformation? + private var purchaseInformation: PurchaseInformation private let screen: CustomerCenterConfigData.Screen? @@ -54,31 +54,27 @@ struct WrongPlatformView: View { body: body) } - init() { - self.screen = nil - } - - init(screen: CustomerCenterConfigData.Screen) { + init(screen: CustomerCenterConfigData.Screen? = nil, + purchaseInformation: PurchaseInformation) { self.screen = screen + self._purchaseInformation = State(initialValue: purchaseInformation) } fileprivate init(store: Store, managementURL: URL?, - subscriptionInformation: PurchaseInformation, + purchaseInformation: PurchaseInformation, screen: CustomerCenterConfigData.Screen) { self.screen = screen self._store = State(initialValue: store) self._managementURL = State(initialValue: managementURL) - self._subscriptionInformation = State(initialValue: subscriptionInformation) + self._purchaseInformation = State(initialValue: purchaseInformation) } var body: some View { List { - if let subscriptionInformation = self.subscriptionInformation { - Section { - SubscriptionDetailsView(purchaseInformation: subscriptionInformation, - refundRequestStatus: nil) - } + Section { + SubscriptionDetailsView(purchaseInformation: purchaseInformation, + refundRequestStatus: nil) } if let managementURL = self.managementURL { Section { @@ -98,7 +94,6 @@ struct WrongPlatformView: View { } } } - } .toolbar { ToolbarItem(placement: .compatibleTopBarTrailing) { @@ -110,39 +105,13 @@ struct WrongPlatformView: View { }) .task { if store == nil { - if let customerInfo = try? await Purchases.shared.customerInfo(), - let entitlement = customerInfo.entitlements.active.first?.value { - self.store = entitlement.store + if let customerInfo = try? await Purchases.shared.customerInfo() { self.managementURL = customerInfo.managementURL - self.subscriptionInformation = PurchaseInformation(entitlement: entitlement) } } } } - private func humanReadableInstructions(for store: Store?) -> String { - let defaultContactSupport = localization.commonLocalizedString(for: .pleaseContactSupportToManage) - - if let store { - switch store { - case .appStore, .macAppStore: - return localization.commonLocalizedString(for: .appleSubscriptionManage) - case .playStore: - return localization.commonLocalizedString(for: .googleSubscriptionManage) - case .stripe, .rcBilling: - return localization.commonLocalizedString(for: .webSubscriptionManage) - case .external, .promotional, .unknownStore: - return defaultContactSupport - case .amazon: - return localization.commonLocalizedString(for: .amazonSubscriptionManage) - @unknown default: - return defaultContactSupport - } - } else { - return defaultContactSupport - } - } - } #if DEBUG @@ -197,7 +166,7 @@ struct WrongPlatformView_Previews: PreviewProvider { WrongPlatformView( store: data.store, managementURL: data.managementURL, - subscriptionInformation: getPurchaseInformation(for: data.customerInfo), + purchaseInformation: getPurchaseInformation(for: data.customerInfo), screen: CustomerCenterConfigTestData.customerCenterData.screens[.management]! ) .previewDisplayName(data.displayName) @@ -206,7 +175,8 @@ struct WrongPlatformView_Previews: PreviewProvider { } private static func getPurchaseInformation(for customerInfo: CustomerInfo) -> PurchaseInformation { - return PurchaseInformation(entitlement: customerInfo.entitlements.active.first!.value) + return PurchaseInformation(entitlement: customerInfo.entitlements.active.first!.value, + transaction: customerInfo.subscriptionsByProductIdentifier.values.first!) } } diff --git a/RevenueCatUI/Data/CustomerInfoFixtures.swift b/RevenueCatUI/Data/CustomerInfoFixtures.swift index 5fc2f845b1..adc3c362fc 100644 --- a/RevenueCatUI/Data/CustomerInfoFixtures.swift +++ b/RevenueCatUI/Data/CustomerInfoFixtures.swift @@ -30,14 +30,18 @@ class CustomerInfoFixtures { self.id = id self.json = """ { + "auto_resume_date": null, "billing_issues_detected_at": null, "expires_date": "\(expirationDate)", "grace_period_expires_date": null, "is_sandbox": true, "original_purchase_date": "\(purchaseDate)", + "ownership_type": "PURCHASED", "period_type": "intro", "purchase_date": "\(purchaseDate)", + "refunded_at": null, "store": "\(store)", + "store_transaction_id": "0", "unsubscribe_detected_at": \(unsubscribeDetectedAt != nil ? "\"\(unsubscribeDetectedAt!)\"" : "null") } """ @@ -55,6 +59,7 @@ class CustomerInfoFixtures { self.json = """ { "expires_date": \(expirationDate != nil ? "\"\(expirationDate!)\"" : "null"), + "grace_period_expires_date": null, "product_identifier": "\(productId)", "purchase_date": "\(purchaseDate)" } @@ -75,8 +80,10 @@ class CustomerInfoFixtures { { "id": "\(id)", "is_sandbox": true, + "original_purchase_date": "\(purchaseDate)", "purchase_date": "\(purchaseDate)", - "store": "\(store)" + "store": "\(store)", + "store_transaction_id": "123" } """ } diff --git a/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift index 997740bfa6..0894a3a185 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift @@ -13,6 +13,8 @@ // Created by Cesar de la Vega on 11/6/24. // +// swiftlint:disable file_length type_body_length function_body_length + import Nimble import RevenueCat @testable import RevenueCatUI @@ -40,8 +42,7 @@ class CustomerCenterViewModelTests: TestCase { let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil) expect(viewModel.state) == .notLoaded - expect(viewModel.hasActiveProducts) == false - expect(viewModel.hasAppleEntitlement) == false + expect(viewModel.purchaseInformation).to(beNil()) expect(viewModel.isLoaded) == false } @@ -69,67 +70,64 @@ class CustomerCenterViewModelTests: TestCase { expect(viewModel.isLoaded) == true } - func testLoadHasSubscriptionsApple() async { - let mockPurchases = MockCustomerCenterPurchases() - mockPurchases.customerInfoResult = .success(CustomerCenterViewModelTests.customerInfoWithAppleSubscriptions) + func testLoadHasSubscriptionsApple() async throws { + let mockPurchases = + MockCustomerCenterPurchases(customerInfo: CustomerCenterViewModelTests.customerInfoWithAppleSubscriptions) let viewModel = CustomerCenterViewModel( customerCenterActionHandler: nil, purchasesProvider: mockPurchases ) - await viewModel.loadHasActivePurchases() + await viewModel.loadPurchaseInformation() - expect(viewModel.hasActiveProducts) == true - expect(viewModel.hasAppleEntitlement) == true + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + expect(purchaseInformation.store) == .appStore expect(viewModel.state) == .success } - func testLoadHasSubscriptionsGoogle() async { - let mockPurchases = MockCustomerCenterPurchases() - mockPurchases.customerInfoResult = .success(CustomerCenterViewModelTests.customerInfoWithGoogleSubscriptions) + func testLoadHasSubscriptionsGoogle() async throws { + let mockPurchases = + MockCustomerCenterPurchases(customerInfo: CustomerCenterViewModelTests.customerInfoWithGoogleSubscriptions) let viewModel = CustomerCenterViewModel( customerCenterActionHandler: nil, purchasesProvider: mockPurchases ) - await viewModel.loadHasActivePurchases() + await viewModel.loadPurchaseInformation() - expect(viewModel.hasActiveProducts) == true - expect(viewModel.hasAppleEntitlement) == false + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + expect(purchaseInformation.store) == .playStore expect(viewModel.state) == .success } func testLoadHasSubscriptionsNonActive() async { - let mockPurchases = MockCustomerCenterPurchases() - mockPurchases.customerInfoResult = .success(CustomerCenterViewModelTests.customerInfoWithoutSubscriptions) + let mockPurchases = + MockCustomerCenterPurchases(customerInfo: CustomerCenterViewModelTests.customerInfoWithoutSubscriptions) let viewModel = CustomerCenterViewModel( customerCenterActionHandler: nil, purchasesProvider: mockPurchases ) - await viewModel.loadHasActivePurchases() + await viewModel.loadPurchaseInformation() - expect(viewModel.hasActiveProducts) == false - expect(viewModel.hasAppleEntitlement) == false + expect(viewModel.purchaseInformation).to(beNil()) expect(viewModel.state) == .success } func testLoadHasSubscriptionsFailure() async { - let mockPurchases = MockCustomerCenterPurchases() - mockPurchases.customerInfoResult = .failure(error) + let mockPurchases = MockCustomerCenterPurchases(customerInfoError: error) let viewModel = CustomerCenterViewModel( customerCenterActionHandler: nil, purchasesProvider: mockPurchases ) - await viewModel.loadHasActivePurchases() + await viewModel.loadPurchaseInformation() - expect(viewModel.hasActiveProducts) == false - expect(viewModel.hasAppleEntitlement) == false + expect(viewModel.purchaseInformation).to(beNil()) switch viewModel.state { case .error(let stateError): expect(stateError as? TestError) == error @@ -138,6 +136,509 @@ class CustomerCenterViewModelTests: TestCase { } } + func testShouldShowActiveSubscription_whenUserHasOneActiveSubscriptionOneEntitlement() async throws { + // Arrange + let productId = "com.revenuecat.product" + let purchaseDate = "2022-04-12T00:03:28Z" + let expirationDate = "2062-04-12T00:03:35Z" + let products = [PurchaseInformationFixtures.product(id: productId, + title: "title", + duration: .month, + price: 2.99)] + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: [ + CustomerInfoFixtures.Subscription( + id: productId, + store: "app_store", + purchaseDate: purchaseDate, + expirationDate: expirationDate + ) + ], + entitlements: [ + CustomerInfoFixtures.Entitlement( + entitlementId: "premium", + productId: productId, + purchaseDate: purchaseDate, + expirationDate: expirationDate + ) + ] + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + // Act + await viewModel.loadPurchaseInformation() + + // Assert + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + expect(purchaseInformation.title) == "title" + expect(purchaseInformation.durationTitle) == "1 month" + + expect(purchaseInformation.price) == .paid("$2.99") + + let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) + expect(expirationOrRenewal.label) == .nextBillingDate + expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: expirationDate)) + + expect(purchaseInformation.productIdentifier) == productId + } + + func testShouldShowActiveSubscription_whenUserHasOneActiveSubscriptionAndNoEntitlement() async throws { + // Arrange + let productId = "com.revenuecat.product" + let purchaseDate = "2022-04-12T00:03:28Z" + let expirationDate = "2062-04-12T00:03:35Z" + let products = [PurchaseInformationFixtures.product(id: productId, + title: "title", + duration: .month, + price: 2.99)] + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: [ + CustomerInfoFixtures.Subscription( + id: productId, + store: "app_store", + purchaseDate: purchaseDate, + expirationDate: expirationDate + ) + ], + entitlements: [ + ] + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + // Act + await viewModel.loadPurchaseInformation() + + // Assert + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + expect(purchaseInformation.title) == "title" + expect(purchaseInformation.durationTitle) == "1 month" + + expect(purchaseInformation.price) == .paid("$2.99") + + let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) + expect(expirationOrRenewal.label) == .nextBillingDate + expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: expirationDate)) + + expect(purchaseInformation.productIdentifier) == productId + } + + func testShouldShowEarliestExpiringSubscription() async throws { + // Arrange + let yearlyProduct = ( + id: "com.revenuecat.yearly", + exp: "2062-04-12T00:03:35Z", // Earlier expiration + title: "yearly", + duration: "1 year", + price: Decimal(29.99) + ) + let monthlyProduct = ( + id: "com.revenuecat.monthly", + exp: "2062-05-12T00:03:35Z", // Later expiration + title: "monthly", + duration: "1 month", + price: Decimal(2.99) + ) + + // Test both possible subscription array orders + let subscriptionOrders = [ + [yearlyProduct, monthlyProduct], + [monthlyProduct, yearlyProduct] + ] + + for subscriptions in subscriptionOrders { + let purchaseDate = "2022-04-12T00:03:28Z" + let products = [ + PurchaseInformationFixtures.product(id: yearlyProduct.id, + title: yearlyProduct.title, + duration: .year, + price: yearlyProduct.price), + PurchaseInformationFixtures.product(id: monthlyProduct.id, + title: monthlyProduct.title, + duration: .month, + price: monthlyProduct.price) + ] + + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: subscriptions.map { product in + CustomerInfoFixtures.Subscription( + id: product.id, + store: "app_store", + purchaseDate: purchaseDate, + expirationDate: product.exp + ) + }, + entitlements: [ + CustomerInfoFixtures.Entitlement( + entitlementId: "premium", + productId: yearlyProduct.id, + purchaseDate: purchaseDate, + expirationDate: yearlyProduct.exp + ) + ] + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + // Act + await viewModel.loadPurchaseInformation() + + // Assert + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + // Should always show yearly subscription since it expires first + expect(purchaseInformation.title) == yearlyProduct.title + expect(purchaseInformation.durationTitle) == yearlyProduct.duration + expect(purchaseInformation.price) == .paid(formatPrice(yearlyProduct.price)) + + let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) + expect(expirationOrRenewal.label) == .nextBillingDate + expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: yearlyProduct.exp)) + + expect(purchaseInformation.productIdentifier) == yearlyProduct.id + } + } + + func testShouldShowClosestExpiring_whenUserHasLifetimeAndSubscriptions() async throws { + let productIdLifetime = "com.revenuecat.simpleapp.lifetime" + let productIdMonthly = "com.revenuecat.simpleapp.monthly" + let productIdYearly = "com.revenuecat.simpleapp.yearly" + let purchaseDateLifetime = "2024-11-21T16:04:20Z" + let purchaseDateMonthly = "2024-11-21T16:04:39Z" + let purchaseDateYearly = "2024-11-21T16:04:45Z" + let expirationDateMonthly = "3024-11-28T16:04:39Z" + let expirationDateYearly = "3025-11-21T16:04:45Z" + + let lifetimeProduct = PurchaseInformationFixtures.product(id: productIdLifetime, + title: "lifetime", + duration: nil, + price: 29.99) + let monthlyProduct = PurchaseInformationFixtures.product(id: productIdMonthly, + title: "monthly", + duration: .month, + price: 2.99) + let yearlyProduct = PurchaseInformationFixtures.product(id: productIdYearly, + title: "yearly", + duration: .year, + price: 29.99) + + let products = [lifetimeProduct, monthlyProduct, yearlyProduct] + + // Test both possible subscription array orders + let subscriptionOrders = [ + [ + (id: productIdMonthly, date: purchaseDateMonthly, exp: expirationDateMonthly), + (id: productIdYearly, date: purchaseDateYearly, exp: expirationDateYearly) + ], + [ + (id: productIdYearly, date: purchaseDateYearly, exp: expirationDateYearly), + (id: productIdMonthly, date: purchaseDateMonthly, exp: expirationDateMonthly) + ] + ] + + for subscriptions in subscriptionOrders { + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: subscriptions.map { subscription in + CustomerInfoFixtures.Subscription( + id: subscription.id, + store: "app_store", + purchaseDate: subscription.date, + expirationDate: subscription.exp + ) + }, + entitlements: [ + CustomerInfoFixtures.Entitlement( + entitlementId: "pro", + productId: productIdLifetime, + purchaseDate: purchaseDateLifetime, + expirationDate: nil + ) + ], + nonSubscriptions: [ + CustomerInfoFixtures.NonSubscriptionTransaction( + productId: productIdLifetime, + id: "2fdd18f128", + store: "app_store", + purchaseDate: purchaseDateLifetime + ) + ] + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + await viewModel.loadPurchaseInformation() + + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + expect(purchaseInformation.title) == "monthly" + expect(purchaseInformation.durationTitle) == "1 month" + expect(purchaseInformation.price) == .paid("$2.99") + expect(purchaseInformation.productIdentifier) == productIdMonthly + + expect(purchaseInformation.expirationOrRenewal?.date) == .date(reformat(ISO8601Date: expirationDateMonthly)) + } + } + + func testShouldShowLifetime_whenUserHasLifetimeOneEntitlement() async throws { + let productIdLifetime = "com.revenuecat.simpleapp.lifetime" + let purchaseDateLifetime = "2024-11-21T16:04:20Z" + + let products = [ + PurchaseInformationFixtures.product(id: productIdLifetime, + title: "lifetime", + duration: nil, + price: 29.99) + ] + + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: [], + entitlements: [ + CustomerInfoFixtures.Entitlement( + entitlementId: "pro", + productId: productIdLifetime, + purchaseDate: purchaseDateLifetime, + expirationDate: nil + ) + ], + nonSubscriptions: [ + CustomerInfoFixtures.NonSubscriptionTransaction( + productId: productIdLifetime, + id: "2fdd18f128", + store: "app_store", + purchaseDate: purchaseDateLifetime + ) + ] + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + await viewModel.loadPurchaseInformation() + + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + expect(purchaseInformation.title) == "lifetime" + expect(purchaseInformation.durationTitle).to(beNil()) + expect(purchaseInformation.price) == .paid("$29.99") + expect(purchaseInformation.productIdentifier) == productIdLifetime + + expect(purchaseInformation.expirationOrRenewal?.date) == .never + } + + func testShouldShowEarliestExpiration_whenUserHasTwoActiveSubscriptionsTwoEntitlements() async throws { + // Arrange + let yearlyProduct = ( + id: "com.revenuecat.product1", + exp: "2062-04-12T00:03:35Z", // Earlier expiration + title: "yearly", + duration: "1 year", + price: Decimal(29.99) + ) + let monthlyProduct = ( + id: "com.revenuecat.product2", + exp: "2062-05-12T00:03:35Z", // Later expiration + title: "monthly", + duration: "1 month", + price: Decimal(2.99) + ) + + // Test both possible subscription and entitlement array orders + let subscriptionOrders = [ + [yearlyProduct, monthlyProduct], + [monthlyProduct, yearlyProduct] + ] + + for subscriptions in subscriptionOrders { + let purchaseDate = "2022-04-12T00:03:28Z" + let products = [ + PurchaseInformationFixtures.product(id: yearlyProduct.id, + title: yearlyProduct.title, + duration: .year, + price: yearlyProduct.price), + PurchaseInformationFixtures.product(id: monthlyProduct.id, + title: monthlyProduct.title, + duration: .month, + price: monthlyProduct.price) + ] + + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: subscriptions.map { product in + CustomerInfoFixtures.Subscription( + id: product.id, + store: "app_store", + purchaseDate: purchaseDate, + expirationDate: product.exp + ) + }, + entitlements: subscriptions.map { product in + CustomerInfoFixtures.Entitlement( + entitlementId: product.id == yearlyProduct.id ? "premium" : "plus", + productId: product.id, + purchaseDate: purchaseDate, + expirationDate: product.exp + ) + } + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + // Act + await viewModel.loadPurchaseInformation() + + // Assert + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + // Should always show yearly subscription since it expires first + expect(purchaseInformation.title) == yearlyProduct.title + expect(purchaseInformation.durationTitle) == yearlyProduct.duration + expect(purchaseInformation.price) == .paid(formatPrice(yearlyProduct.price)) + + let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) + expect(expirationOrRenewal.label) == .nextBillingDate + expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: yearlyProduct.exp)) + + expect(purchaseInformation.productIdentifier) == yearlyProduct.id + } + } + + func testShouldShowAppleSubscription_whenUserHasBothGoogleAndAppleSubscriptions() async throws { + // Arrange + let googleProduct = ( + id: "com.revenuecat.product1", + store: "play_store", + exp: "2062-04-12T00:03:35Z", + title: "yearly", + duration: "1 year", + price: Decimal(29.99) + ) + let appleProduct = ( + id: "com.revenuecat.product2", + store: "app_store", + exp: "2062-05-12T00:03:35Z", + title: "monthly", + duration: "1 month", + price: Decimal(2.99) + ) + + // Test both possible subscription and entitlement array orders + let subscriptionOrders = [ + [googleProduct, appleProduct], + [appleProduct, googleProduct] + ] + + for subscriptions in subscriptionOrders { + let purchaseDate = "2022-04-12T00:03:28Z" + let products = [ + PurchaseInformationFixtures.product(id: googleProduct.id, + title: googleProduct.title, + duration: .year, + price: googleProduct.price), + PurchaseInformationFixtures.product(id: appleProduct.id, + title: appleProduct.title, + duration: .month, + price: appleProduct.price) + ] + + let customerInfo = CustomerInfoFixtures.customerInfo( + subscriptions: subscriptions.map { product in + CustomerInfoFixtures.Subscription( + id: product.id, + store: product.store, + purchaseDate: purchaseDate, + expirationDate: product.exp + ) + }, + entitlements: subscriptions.map { product in + CustomerInfoFixtures.Entitlement( + entitlementId: product.id == googleProduct.id ? "premium" : "plus", + productId: product.id, + purchaseDate: purchaseDate, + expirationDate: product.exp + ) + } + ) + + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: MockCustomerCenterPurchases( + customerInfo: customerInfo, + products: products + )) + + // Act + await viewModel.loadPurchaseInformation() + + // Assert + expect(viewModel.state) == .success + + let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) + // We expect to see the monthly one, because the yearly one is a Google subscription + expect(purchaseInformation.title) == appleProduct.title + expect(purchaseInformation.durationTitle) == appleProduct.duration + expect(purchaseInformation.price) == .paid(formatPrice(appleProduct.price)) + + let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) + expect(expirationOrRenewal.label) == .nextBillingDate + expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: appleProduct.exp)) + + expect(purchaseInformation.productIdentifier) == appleProduct.id + } + } + + func testLoadScreenNoActiveSubscription() async { + let customerInfo = CustomerInfoFixtures.customerInfoWithExpiredAppleSubscriptions + let mockPurchases = MockCustomerCenterPurchases(customerInfo: customerInfo) + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: mockPurchases) + + await viewModel.loadPurchaseInformation() + + expect(viewModel.purchaseInformation).to(beNil()) + expect(viewModel.state) == .success + } + + func testLoadScreenFailure() async { + let mockPurchases = MockCustomerCenterPurchases(customerInfoError: error) + let viewModel = CustomerCenterViewModel(customerCenterActionHandler: nil, + purchasesProvider: mockPurchases) + + await viewModel.loadPurchaseInformation() + + expect(viewModel.purchaseInformation).to(beNil()) + expect(viewModel.state) == .error(error) + } + func testAppIsLatestVersion() { let testCases = [ (currentVersion: "1.0.0", latestVersion: "2.0.0", expectedAppIsLatestVersion: false), @@ -355,6 +856,16 @@ private extension CustomerCenterViewModelTests { ) }() + func reformat(ISO8601Date: String) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: ISO8601DateFormatter().date(from: ISO8601Date)!) + } + + func formatPrice(_ price: Decimal) -> String { + "$\(String(format: "%.2f", NSDecimalNumber(decimal: price).doubleValue))" + } + } #endif diff --git a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift index 66c0f5e8ae..f7c62c9950 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift @@ -39,20 +39,15 @@ class ManageSubscriptionsViewModelTests: TestCase { } } - private func formatPrice(_ price: Decimal) -> String { - "$\(String(format: "%.2f", NSDecimalNumber(decimal: price).doubleValue))" - } - func testInitialState() { let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, customerCenterActionHandler: nil) - expect(viewModel.state) == CustomerCenterViewState.notLoaded + expect(viewModel.state) == CustomerCenterViewState.success expect(viewModel.purchaseInformation).to(beNil()) expect(viewModel.refundRequestStatus).to(beNil()) expect(viewModel.screen).toNot(beNil()) expect(viewModel.showRestoreAlert) == false - expect(viewModel.isLoaded) == false } func testStateChangeToError() { @@ -69,495 +64,6 @@ class ManageSubscriptionsViewModelTests: TestCase { } } - func testIsLoaded() { - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil) - - expect(viewModel.isLoaded) == false - - viewModel.state = .success - - expect(viewModel.isLoaded) == true - } - - func testShouldShowActiveSubscription_whenUserHasOneActiveSubscriptionOneEntitlement() async throws { - // Arrange - let productId = "com.revenuecat.product" - let purchaseDate = "2022-04-12T00:03:28Z" - let expirationDate = "2062-04-12T00:03:35Z" - let products = [PurchaseInformationFixtures.product(id: productId, - title: "title", - duration: .month, - price: 2.99)] - let customerInfo = CustomerInfoFixtures.customerInfo( - subscriptions: [ - CustomerInfoFixtures.Subscription( - id: productId, - store: "app_store", - purchaseDate: purchaseDate, - expirationDate: expirationDate - ) - ], - entitlements: [ - CustomerInfoFixtures.Entitlement( - entitlementId: "premium", - productId: productId, - purchaseDate: purchaseDate, - expirationDate: expirationDate - ) - ] - ) - - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: MockManageSubscriptionsPurchases( - customerInfo: customerInfo, - products: products - ), - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - // Act - await viewModel.loadScreen() - - // Assert - expect(viewModel.screen).toNot(beNil()) - expect(viewModel.state) == .success - - let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) - expect(purchaseInformation.title) == "title" - expect(purchaseInformation.durationTitle) == "1 month" - - expect(purchaseInformation.price) == .paid("$2.99") - - let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) - expect(expirationOrRenewal.label) == .nextBillingDate - expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: expirationDate)) - - expect(purchaseInformation.productIdentifier) == productId - } - - func testShouldShowEarliestExpiration_whenUserHasTwoActiveSubscriptionsOneEntitlement() async throws { - // Arrange - let yearlyProduct = ( - id: "com.revenuecat.yearly", - exp: "2062-04-12T00:03:35Z", // Earlier expiration - title: "yearly", - duration: "1 year", - price: Decimal(29.99) - ) - let monthlyProduct = ( - id: "com.revenuecat.monthly", - exp: "2062-05-12T00:03:35Z", // Later expiration - title: "monthly", - duration: "1 month", - price: Decimal(2.99) - ) - - // Test both possible subscription array orders - let subscriptionOrders = [ - [yearlyProduct, monthlyProduct], - [monthlyProduct, yearlyProduct] - ] - - for subscriptions in subscriptionOrders { - let purchaseDate = "2022-04-12T00:03:28Z" - let products = [ - PurchaseInformationFixtures.product(id: yearlyProduct.id, - title: yearlyProduct.title, - duration: .year, - price: yearlyProduct.price), - PurchaseInformationFixtures.product(id: monthlyProduct.id, - title: monthlyProduct.title, - duration: .month, - price: monthlyProduct.price) - ] - - let customerInfo = CustomerInfoFixtures.customerInfo( - subscriptions: subscriptions.map { product in - CustomerInfoFixtures.Subscription( - id: product.id, - store: "app_store", - purchaseDate: purchaseDate, - expirationDate: product.exp - ) - }, - entitlements: [ - CustomerInfoFixtures.Entitlement( - entitlementId: "premium", - productId: yearlyProduct.id, - purchaseDate: purchaseDate, - expirationDate: yearlyProduct.exp - ) - ] - ) - - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: MockManageSubscriptionsPurchases( - customerInfo: customerInfo, - products: products - ), - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - // Act - await viewModel.loadScreen() - - // Assert - expect(viewModel.screen).toNot(beNil()) - expect(viewModel.state) == .success - - let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) - // Should always show yearly subscription since it expires first - expect(purchaseInformation.title) == yearlyProduct.title - expect(purchaseInformation.durationTitle) == yearlyProduct.duration - expect(purchaseInformation.price) == .paid(formatPrice(yearlyProduct.price)) - - let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) - expect(expirationOrRenewal.label) == .nextBillingDate - expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: yearlyProduct.exp)) - - expect(purchaseInformation.productIdentifier) == yearlyProduct.id - } - } - - func testShouldShowLifetime_whenUserHasLifetimeAndSubscriptionsOneEntitlement() async throws { - let productIdLifetime = "com.revenuecat.simpleapp.lifetime" - let productIdMonthly = "com.revenuecat.simpleapp.monthly" - let productIdYearly = "com.revenuecat.simpleapp.yearly" - let purchaseDateLifetime = "2024-11-21T16:04:20Z" - let purchaseDateMonthly = "2024-11-21T16:04:39Z" - let purchaseDateYearly = "2024-11-21T16:04:45Z" - let expirationDateMonthly = "2024-11-28T16:04:39Z" - let expirationDateYearly = "2025-11-21T16:04:45Z" - - let products = [ - PurchaseInformationFixtures.product(id: productIdLifetime, - title: "lifetime", - duration: nil, - price: 29.99), - PurchaseInformationFixtures.product(id: productIdMonthly, - title: "monthly", - duration: .month, - price: 2.99), - PurchaseInformationFixtures.product(id: productIdYearly, - title: "yearly", - duration: .year, - price: 29.99) - ] - - // Test both possible subscription array orders - let subscriptionOrders = [ - [ - (id: productIdMonthly, date: purchaseDateMonthly, exp: expirationDateMonthly), - (id: productIdYearly, date: purchaseDateYearly, exp: expirationDateYearly) - ], - [ - (id: productIdYearly, date: purchaseDateYearly, exp: expirationDateYearly), - (id: productIdMonthly, date: purchaseDateMonthly, exp: expirationDateMonthly) - ] - ] - - for subscriptions in subscriptionOrders { - let customerInfo = CustomerInfoFixtures.customerInfo( - subscriptions: subscriptions.map { subscription in - CustomerInfoFixtures.Subscription( - id: subscription.id, - store: "app_store", - purchaseDate: subscription.date, - expirationDate: subscription.exp - ) - }, - entitlements: [ - CustomerInfoFixtures.Entitlement( - entitlementId: "pro", - productId: productIdLifetime, - purchaseDate: purchaseDateLifetime, - expirationDate: nil - ) - ], - nonSubscriptions: [ - CustomerInfoFixtures.NonSubscriptionTransaction( - productId: productIdLifetime, - id: "2fdd18f128", - store: "app_store", - purchaseDate: purchaseDateLifetime - ) - ] - ) - - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: MockManageSubscriptionsPurchases( - customerInfo: customerInfo, - products: products - ), - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - await viewModel.loadScreen() - - expect(viewModel.screen).toNot(beNil()) - expect(viewModel.state) == .success - - let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) - expect(purchaseInformation.title) == "lifetime" - expect(purchaseInformation.durationTitle).to(beNil()) - expect(purchaseInformation.price) == .paid("$29.99") - expect(purchaseInformation.productIdentifier) == productIdLifetime - - expect(purchaseInformation.expirationOrRenewal?.date) == .never - } - } - - func testShouldShowLifetime_whenUserHasLifetimeOneEntitlement() async throws { - let productIdLifetime = "com.revenuecat.simpleapp.lifetime" - let purchaseDateLifetime = "2024-11-21T16:04:20Z" - - let products = [ - PurchaseInformationFixtures.product(id: productIdLifetime, - title: "lifetime", - duration: nil, - price: 29.99) - ] - - let customerInfo = CustomerInfoFixtures.customerInfo( - subscriptions: [], - entitlements: [ - CustomerInfoFixtures.Entitlement( - entitlementId: "pro", - productId: productIdLifetime, - purchaseDate: purchaseDateLifetime, - expirationDate: nil - ) - ], - nonSubscriptions: [ - CustomerInfoFixtures.NonSubscriptionTransaction( - productId: productIdLifetime, - id: "2fdd18f128", - store: "app_store", - purchaseDate: purchaseDateLifetime - ) - ] - ) - - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: MockManageSubscriptionsPurchases( - customerInfo: customerInfo, - products: products - ), - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - await viewModel.loadScreen() - - expect(viewModel.screen).toNot(beNil()) - expect(viewModel.state) == .success - - let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) - expect(purchaseInformation.title) == "lifetime" - expect(purchaseInformation.durationTitle).to(beNil()) - expect(purchaseInformation.price) == .paid("$29.99") - expect(purchaseInformation.productIdentifier) == productIdLifetime - - expect(purchaseInformation.expirationOrRenewal?.date) == .never - } - - func testShouldShowEarliestExpiration_whenUserHasTwoActiveSubscriptionsTwoEntitlements() async throws { - // Arrange - let yearlyProduct = ( - id: "com.revenuecat.product1", - exp: "2062-04-12T00:03:35Z", // Earlier expiration - title: "yearly", - duration: "1 year", - price: Decimal(29.99) - ) - let monthlyProduct = ( - id: "com.revenuecat.product2", - exp: "2062-05-12T00:03:35Z", // Later expiration - title: "monthly", - duration: "1 month", - price: Decimal(2.99) - ) - - // Test both possible subscription and entitlement array orders - let subscriptionOrders = [ - [yearlyProduct, monthlyProduct], - [monthlyProduct, yearlyProduct] - ] - - for subscriptions in subscriptionOrders { - let purchaseDate = "2022-04-12T00:03:28Z" - let products = [ - PurchaseInformationFixtures.product(id: yearlyProduct.id, - title: yearlyProduct.title, - duration: .year, - price: yearlyProduct.price), - PurchaseInformationFixtures.product(id: monthlyProduct.id, - title: monthlyProduct.title, - duration: .month, - price: monthlyProduct.price) - ] - - let customerInfo = CustomerInfoFixtures.customerInfo( - subscriptions: subscriptions.map { product in - CustomerInfoFixtures.Subscription( - id: product.id, - store: "app_store", - purchaseDate: purchaseDate, - expirationDate: product.exp - ) - }, - entitlements: subscriptions.map { product in - CustomerInfoFixtures.Entitlement( - entitlementId: product.id == yearlyProduct.id ? "premium" : "plus", - productId: product.id, - purchaseDate: purchaseDate, - expirationDate: product.exp - ) - } - ) - - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: MockManageSubscriptionsPurchases( - customerInfo: customerInfo, - products: products - ), - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - // Act - await viewModel.loadScreen() - - // Assert - expect(viewModel.screen).toNot(beNil()) - expect(viewModel.state) == .success - - let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) - // Should always show yearly subscription since it expires first - expect(purchaseInformation.title) == yearlyProduct.title - expect(purchaseInformation.durationTitle) == yearlyProduct.duration - expect(purchaseInformation.price) == .paid(formatPrice(yearlyProduct.price)) - - let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) - expect(expirationOrRenewal.label) == .nextBillingDate - expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: yearlyProduct.exp)) - - expect(purchaseInformation.productIdentifier) == yearlyProduct.id - } - } - - func testShouldShowAppleSubscription_whenUserHasBothGoogleAndAppleSubscriptions() async throws { - // Arrange - let googleProduct = ( - id: "com.revenuecat.product1", - store: "play_store", - exp: "2062-04-12T00:03:35Z", - title: "yearly", - duration: "1 year", - price: Decimal(29.99) - ) - let appleProduct = ( - id: "com.revenuecat.product2", - store: "app_store", - exp: "2062-05-12T00:03:35Z", - title: "monthly", - duration: "1 month", - price: Decimal(2.99) - ) - - // Test both possible subscription and entitlement array orders - let subscriptionOrders = [ - [googleProduct, appleProduct], - [appleProduct, googleProduct] - ] - - for subscriptions in subscriptionOrders { - let purchaseDate = "2022-04-12T00:03:28Z" - let products = [ - PurchaseInformationFixtures.product(id: googleProduct.id, - title: googleProduct.title, - duration: .year, - price: googleProduct.price), - PurchaseInformationFixtures.product(id: appleProduct.id, - title: appleProduct.title, - duration: .month, - price: appleProduct.price) - ] - - let customerInfo = CustomerInfoFixtures.customerInfo( - subscriptions: subscriptions.map { product in - CustomerInfoFixtures.Subscription( - id: product.id, - store: product.store, - purchaseDate: purchaseDate, - expirationDate: product.exp - ) - }, - entitlements: subscriptions.map { product in - CustomerInfoFixtures.Entitlement( - entitlementId: product.id == googleProduct.id ? "premium" : "plus", - productId: product.id, - purchaseDate: purchaseDate, - expirationDate: product.exp - ) - } - ) - - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: MockManageSubscriptionsPurchases( - customerInfo: customerInfo, - products: products - ), - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - // Act - await viewModel.loadScreen() - - // Assert - expect(viewModel.screen).toNot(beNil()) - expect(viewModel.state) == .success - - let purchaseInformation = try XCTUnwrap(viewModel.purchaseInformation) - // We expect to see the monthly one, because the yearly one is a Google subscription - expect(purchaseInformation.title) == appleProduct.title - expect(purchaseInformation.durationTitle) == appleProduct.duration - expect(purchaseInformation.price) == .paid(formatPrice(appleProduct.price)) - - let expirationOrRenewal = try XCTUnwrap(purchaseInformation.expirationOrRenewal) - expect(expirationOrRenewal.label) == .nextBillingDate - expect(expirationOrRenewal.date) == .date(reformat(ISO8601Date: appleProduct.exp)) - - expect(purchaseInformation.productIdentifier) == appleProduct.id - } - } - - func testLoadScreenNoActiveSubscription() async { - let customerInfo = CustomerInfoFixtures.customerInfoWithExpiredAppleSubscriptions - let mockPurchases = MockManageSubscriptionsPurchases(customerInfo: customerInfo) - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: mockPurchases, - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - await viewModel.loadScreen() - - expect(viewModel.purchaseInformation).to(beNil()) - expect(viewModel.state) == .error(CustomerCenterError.couldNotFindSubscriptionInformation) - } - - func testLoadScreenFailure() async { - let mockPurchases = MockManageSubscriptionsPurchases(customerInfoError: error) - let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, - customerCenterActionHandler: nil, - purchasesProvider: mockPurchases, - loadPromotionalOfferUseCase: MockLoadPromotionalOfferUseCase()) - - await viewModel.loadScreen() - - expect(viewModel.purchaseInformation).to(beNil()) - expect(viewModel.state) == .error(error) - } - func testLoadsPromotionalOffer() async throws { let offerIdentifierInJSON = "rc_refund_offer" let (viewModel, loadPromotionalOfferUseCase) = try await setupPromotionalOfferTest( @@ -682,8 +188,6 @@ class ManageSubscriptionsViewModelTests: TestCase { loadPromotionalOfferUseCase: loadPromotionalOfferUseCase ) - await viewModel.loadScreen() - let screen = try XCTUnwrap(viewModel.screen) expect(viewModel.state) == .success @@ -786,8 +290,6 @@ class ManageSubscriptionsViewModelTests: TestCase { ), loadPromotionalOfferUseCase: loadPromotionalOfferUseCase) - await viewModel.loadScreen() - return (viewModel, loadPromotionalOfferUseCase) } @@ -821,12 +323,6 @@ class ManageSubscriptionsViewModelTests: TestCase { } } - private func reformat(ISO8601Date: String) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: ISO8601DateFormatter().date(from: ISO8601Date)!) - } - } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) diff --git a/Tests/RevenueCatUITests/CustomerCenter/MockCustomerCenterPurchases.swift b/Tests/RevenueCatUITests/CustomerCenter/MockCustomerCenterPurchases.swift index 4534f14651..bcc2b2bfde 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/MockCustomerCenterPurchases.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/MockCustomerCenterPurchases.swift @@ -21,20 +21,46 @@ import RevenueCat @available(watchOS, unavailable) final class MockCustomerCenterPurchases: @unchecked Sendable, CustomerCenterPurchasesType { + let customerInfo: CustomerInfo + let customerInfoError: Error? + // StoreProducts keyed by productIdentifier. + let products: [String: RevenueCat.StoreProduct] + let showManageSubscriptionsError: Error? + let beginRefundShouldFail: Bool + var isSandbox: Bool = false - var customerInfoCallCount = 0 - var customerInfoResult: Result = .failure(NSError(domain: "", code: -1)) - func customerInfo() async throws -> CustomerInfo { - customerInfoCallCount += 1 - return try customerInfoResult.get() + init( + customerInfo: CustomerInfo = CustomerInfoFixtures.customerInfoWithAppleSubscriptions, + customerInfoError: Error? = nil, + products: [RevenueCat.StoreProduct] = + [PurchaseInformationFixtures.product(id: "com.revenuecat.product", + title: "title", + duration: .month, + price: 2.99)], + showManageSubscriptionsError: Error? = nil, + beginRefundShouldFail: Bool = false + ) { + self.customerInfo = customerInfo + self.customerInfoError = customerInfoError + self.products = Dictionary(uniqueKeysWithValues: products.map({ product in + (product.productIdentifier, product) + })) + self.showManageSubscriptionsError = showManageSubscriptionsError + self.beginRefundShouldFail = beginRefundShouldFail + } + + func customerInfo() async throws -> RevenueCat.CustomerInfo { + if let customerInfoError { + throw customerInfoError + } + return customerInfo } - var productsCallCount = 0 - var productsResult: [StoreProduct] = [] - func products(_ productIdentifiers: [String]) async -> [StoreProduct] { - productsCallCount += 1 - return productsResult + func products(_ productIdentifiers: [String]) async -> [RevenueCat.StoreProduct] { + return productIdentifiers.compactMap { productIdentifier in + products[productIdentifier] + } } var promotionalOfferCallCount = 0 diff --git a/Tests/RevenueCatUITests/CustomerCenter/SubscriptionInformationTests.swift b/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift similarity index 70% rename from Tests/RevenueCatUITests/CustomerCenter/SubscriptionInformationTests.swift rename to Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift index 64bf2c3275..0266c62c5a 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/SubscriptionInformationTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift @@ -17,6 +17,7 @@ import XCTest import RevenueCat @testable import RevenueCatUI +// swiftlint:disable file_length type_body_length @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @available(macOS, unavailable) @available(tvOS, unavailable) @@ -32,6 +33,12 @@ class PurchaseInformationTests: TestCase { return formatter }() + private struct MockTransaction: Transaction { + let productIdentifier: String + let store: Store + let type: TransactionType + } + func testAppleEntitlementAndSubscribedProduct() throws { let customerInfo = CustomerInfoFixtures.customerInfoWithAppleSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) @@ -49,8 +56,19 @@ class PurchaseInformationTests: TestCase { locale: Self.locale ) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .appStore, + type: .subscription( + isActive: true, + willRenew: true, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, subscribedProduct: mockProduct.toStoreProduct(), + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title) == "Monthly Product" expect(subscriptionInfo.durationTitle) == "1 month" @@ -82,8 +100,19 @@ class PurchaseInformationTests: TestCase { locale: Self.locale ) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .appStore, + type: .subscription( + isActive: true, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, subscribedProduct: mockProduct.toStoreProduct(), + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title) == "Monthly Product" expect(subscriptionInfo.durationTitle) == "1 month" @@ -115,8 +144,19 @@ class PurchaseInformationTests: TestCase { locale: Self.locale ) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .appStore, + type: .subscription( + isActive: false, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2000") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, subscribedProduct: mockProduct.toStoreProduct(), + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title) == "Monthly Product" expect(subscriptionInfo.durationTitle) == "1 month" @@ -135,7 +175,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithGoogleSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .playStore, + type: .subscription( + isActive: true, + willRenew: true, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -155,7 +206,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithNonRenewingGoogleSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .playStore, + type: .subscription( + isActive: true, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -175,7 +237,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithExpiredGoogleSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .playStore, + type: .subscription( + isActive: false, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2000") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -195,7 +268,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithPromotional let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .promotional, + type: .subscription( + isActive: true, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -215,7 +299,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithLifetimePromotional let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .promotional, + type: .subscription( + isActive: true, + willRenew: false, + expiresDate: nil + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -235,7 +330,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithStripeSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .stripe, + type: .subscription( + isActive: true, + willRenew: true, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -255,7 +361,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithNonRenewingStripeSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .stripe, + type: .subscription( + isActive: true, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2062") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -275,7 +392,18 @@ class PurchaseInformationTests: TestCase { let customerInfo = CustomerInfoFixtures.customerInfoWithExpiredStripeSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .stripe, + type: .subscription( + isActive: false, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2000") + ) + ) + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) expect(subscriptionInfo.title).to(beNil()) @@ -291,38 +419,32 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.store) == .stripe } - func testLoadingOnlyWithProductInformation() throws { - let customerInfo = CustomerInfoFixtures.customerInfoWithNonRenewingAppleSubscriptions - let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) - - let mockProduct = TestStoreProduct( - localizedTitle: "Monthly Product", - price: 6.99, - localizedPriceString: "$6.99", - productIdentifier: entitlement.productIdentifier, - productType: .autoRenewableSubscription, - localizedDescription: "PRO monthly", - subscriptionGroupIdentifier: "group", - subscriptionPeriod: .init(value: 1, unit: .month), - introductoryDiscount: nil, - locale: Self.locale + func testLoadingOnlyWithOnlyPurchaseInformation() throws { + let mockTransaction = MockTransaction( + productIdentifier: "product_id", + store: .stripe, + type: .subscription( + isActive: false, + willRenew: false, + expiresDate: Self.mockDateFormatter.date(from: "Apr 12, 2000") + ) ) - let testDate = Self.mockDateFormatter.date(from: "Apr 12, 2062")! - let subscriptionInfo = try XCTUnwrap(PurchaseInformation(product: mockProduct.toStoreProduct(), - expirationDate: testDate, + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: nil, + subscribedProduct: nil, + transaction: mockTransaction, dateFormatter: Self.mockDateFormatter)) - expect(subscriptionInfo.title) == "Monthly Product" - expect(subscriptionInfo.explanation) == .earliestRenewal - expect(subscriptionInfo.durationTitle) == "1 month" - expect(subscriptionInfo.price) == .paid("$6.99") + expect(subscriptionInfo.title).to(beNil()) + expect(subscriptionInfo.explanation) == .expired + expect(subscriptionInfo.durationTitle).to(beNil()) + expect(subscriptionInfo.price) == .unknown let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) - expect(expirationOrRenewal.label) == .expires - expect(expirationOrRenewal.date) == .date("Apr 12, 2062") + expect(expirationOrRenewal.label) == .expired + expect(expirationOrRenewal.date) == .date("Apr 12, 2000") - expect(subscriptionInfo.productIdentifier) == entitlement.productIdentifier - expect(subscriptionInfo.store) == .appStore + expect(subscriptionInfo.productIdentifier) == "product_id" + expect(subscriptionInfo.store) == .stripe } }