Skip to content
Permalink

Comparing changes

This is a direct comparison between two commits made in this repository or its related repositories. View the default comparison for this range or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: RevenueCat/purchases-ios
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: a9430db84814b50847b9d46f456d1eaf731e9134
Choose a base ref
..
head repository: RevenueCat/purchases-ios
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: f4116bb6008b0fa2f9af6c021315c5468a63be9b
Choose a head ref
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -129,6 +129,7 @@
2DFF6C56270CA28800ECAFAB /* MockRequestFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 351B517926D44FF000BD2BD7 /* MockRequestFetcher.swift */; };
35109DAB2BC6E436001030C8 /* BackendPostDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35109DAA2BC6E436001030C8 /* BackendPostDiagnosticsTests.swift */; };
35109DB92BC8143E001030C8 /* DiagnosticsEventsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35109DB82BC8143E001030C8 /* DiagnosticsEventsRequest.swift */; };
3511088D2C47E7970048C4D8 /* PromotionalOfferViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 350419422C47D09A002B205A /* PromotionalOfferViewModelTests.swift */; };
351B513D26D4491E00BD2BD7 /* MockDeviceCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 351B513C26D4491E00BD2BD7 /* MockDeviceCache.swift */; };
351B513F26D4496000BD2BD7 /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 351B513E26D4496000BD2BD7 /* MockIdentityManager.swift */; };
351B514126D4498F00BD2BD7 /* MockBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 351B514026D4498F00BD2BD7 /* MockBackend.swift */; };
@@ -1139,6 +1140,7 @@
2DEAC2E526EFE470006914ED /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2DEAC2EA26EFE470006914ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
33FFC8744F2BAE7BD8889A4C /* Pods_RevenueCat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RevenueCat.framework; sourceTree = BUILT_PRODUCTS_DIR; };
350419422C47D09A002B205A /* PromotionalOfferViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionalOfferViewModelTests.swift; sourceTree = "<group>"; };
350A1B84226E3E8700CCA10F /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; };
35109DAA2BC6E436001030C8 /* BackendPostDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendPostDiagnosticsTests.swift; sourceTree = "<group>"; };
35109DB82BC8143E001030C8 /* DiagnosticsEventsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsEventsRequest.swift; sourceTree = "<group>"; };
@@ -2683,6 +2685,7 @@
children = (
3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */,
3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */,
350419422C47D09A002B205A /* PromotionalOfferViewModelTests.swift */,
);
path = CustomerCenter;
sourceTree = "<group>";
@@ -5285,6 +5288,7 @@
887A632E2C1D177800E1A461 /* TemplateViewConfigurationTests.swift in Sources */,
887A632F2C1D177800E1A461 /* VariablesTests.swift in Sources */,
887A63302C1D177800E1A461 /* AsyncTestHelpers.swift in Sources */,
3511088D2C47E7970048C4D8 /* PromotionalOfferViewModelTests.swift in Sources */,
887A63312C1D177800E1A461 /* AvailabilityChecks.swift in Sources */,
887A63322C1D177800E1A461 /* CurrentTestCaseTracker.swift in Sources */,
887A63332C1D177800E1A461 /* DataExtensions.swift in Sources */,
Original file line number Diff line number Diff line change
@@ -25,5 +25,8 @@ protocol PromotionalOfferPurchaseType: Sendable {

@Sendable
func products(_ productIdentifiers: [String]) async -> [StoreProduct]

func promotionalOffer(forProductDiscount discount: StoreProductDiscount,
product: StoreProduct) async throws -> PromotionalOffer

}
Original file line number Diff line number Diff line change
@@ -36,7 +36,12 @@ enum CustomerCenterConfigTestData {
id: "2",
title: "Request a refund",
type: .refundRequest,
detail: nil
detail: .promotionalOffer(CustomerCenterConfigData.HelpPath.PromotionalOffer(
iosOfferId: "offer_id",
eligible: true,
title: "title",
subtitle: "subtitle"
))
),
.init(
id: "3",
Original file line number Diff line number Diff line change
@@ -91,7 +91,8 @@ class PromotionalOfferViewModel: ObservableObject {
let customerInfo = try await self.purchasesProvider.customerInfo()

guard let currentEntitlement = customerInfo.currentEntitlement(),
let subscribedProduct = await purchasesProvider.products([currentEntitlement.productIdentifier]).first
let subscribedProduct =
await self.purchasesProvider.products([currentEntitlement.productIdentifier]).first
else {
Logger.warning(Strings.could_not_offer_for_active_subscriptions)
self.error = CustomerCenterError.couldNotFindSubscriptionInformation
@@ -106,8 +107,8 @@ class PromotionalOfferViewModel: ObservableObject {
return
}

let promotionalOffer = try await Purchases.shared.promotionalOffer(forProductDiscount: discount,
product: subscribedProduct)
let promotionalOffer = try await self.purchasesProvider.promotionalOffer(forProductDiscount: discount,
product: subscribedProduct)
self.promotionalOffer = promotionalOffer
self.product = subscribedProduct
} catch {
@@ -133,6 +134,12 @@ private final class PromotionalOfferPurchases: PromotionalOfferPurchaseType {
await Purchases.shared.products(productIdentifiers)
}

func promotionalOffer(forProductDiscount discount: StoreProductDiscount,
product: StoreProduct) async throws -> PromotionalOffer {
try await Purchases.shared.promotionalOffer(forProductDiscount: discount,
product: product)
}

}

private extension CustomerInfo {
2 changes: 1 addition & 1 deletion Sources/CustomerCenter/CustomerCenterConfigData.swift
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@ public struct CustomerCenterConfigData {
}

public enum CommonLocalizedString: String {

case noThanks = "no_thanks"

}
Original file line number Diff line number Diff line change
@@ -39,8 +39,13 @@ func checkHelpPathDetail(_ detail: CustomerCenterConfigData.HelpPath.PathDetail)
func checkPromotionalOffer(_ offer: CustomerCenterConfigData.HelpPath.PromotionalOffer) {
let iosOfferId: String = offer.iosOfferId
let eligible: Bool = offer.eligible
let title: String = offer.title
let subtitle: String = offer.subtitle

let _: CustomerCenterConfigData.HelpPath.PromotionalOffer = .init(iosOfferId: iosOfferId, eligible: eligible)
let _: CustomerCenterConfigData.HelpPath.PromotionalOffer = .init(iosOfferId: iosOfferId,
eligible: eligible,
title: title,
subtitle: subtitle)
}

func checkFeedbackSurvey(_ survey: CustomerCenterConfigData.HelpPath.FeedbackSurvey) {
@@ -53,8 +58,11 @@ func checkFeedbackSurvey(_ survey: CustomerCenterConfigData.HelpPath.FeedbackSur
func checkFeedbackSurveyOption(_ option: CustomerCenterConfigData.HelpPath.FeedbackSurvey.Option) {
let id: String = option.id
let title: String = option.title
let promotionalOffer: CustomerCenterConfigData.HelpPath.PromotionalOffer? = option.promotionalOffer

let _: CustomerCenterConfigData.HelpPath.FeedbackSurvey.Option = .init(id: id, title: title)
let _: CustomerCenterConfigData.HelpPath.FeedbackSurvey.Option = .init(id: id,
title: title,
promotionalOffer: promotionalOffer)
}

func checkScreen(_ screen: CustomerCenterConfigData.Screen) {
Original file line number Diff line number Diff line change
@@ -102,7 +102,8 @@ class ManageSubscriptionsViewModelTests: TestCase {
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfo: customerInfo,
products: products
))
),
promotionalOfferViewModel: MockPromotionalOfferViewModel())

// Act
await viewModel.loadScreen()
@@ -159,7 +160,8 @@ class ManageSubscriptionsViewModelTests: TestCase {
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfo: customerInfo,
products: products
))
),
promotionalOfferViewModel: MockPromotionalOfferViewModel())

// Act
await viewModel.loadScreen()
@@ -222,7 +224,8 @@ class ManageSubscriptionsViewModelTests: TestCase {
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfo: customerInfo,
products: products
))
),
promotionalOfferViewModel: MockPromotionalOfferViewModel())

// Act
await viewModel.loadScreen()
@@ -285,7 +288,8 @@ class ManageSubscriptionsViewModelTests: TestCase {
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfo: customerInfo,
products: products
))
),
promotionalOfferViewModel: MockPromotionalOfferViewModel())

// Act
await viewModel.loadScreen()
@@ -304,10 +308,10 @@ class ManageSubscriptionsViewModelTests: TestCase {
}

func testLoadScreenNoActiveSubscription() async {
let mockPurchases = MockManageSubscriptionsPurchases(customerInfo: Fixtures.customerInfoWithoutSubscriptions)
let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen,
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfo: Fixtures.customerInfoWithoutSubscriptions
))
purchasesProvider: mockPurchases,
promotionalOfferViewModel: MockPromotionalOfferViewModel())

await viewModel.loadScreen()

@@ -316,17 +320,89 @@ class ManageSubscriptionsViewModelTests: TestCase {
}

func testLoadScreenFailure() async {
let mockPurchases = MockManageSubscriptionsPurchases(customerInfoError: error)
let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen,
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfoError: error
))
purchasesProvider: mockPurchases,
promotionalOfferViewModel: MockPromotionalOfferViewModel())

await viewModel.loadScreen()

expect(viewModel.subscriptionInformation).to(beNil())
expect(viewModel.state) == .error(error)
}

func testLoadsPromotionalOffer() async throws {
let productIdOne = "com.revenuecat.product1"
let productIdTwo = "com.revenuecat.product2"
let purchaseDate = "2022-04-12T00:03:28Z"
let expirationDateFirst = "2062-04-12T00:03:35Z"
let expirationDateSecond = "2062-05-12T00:03:35Z"
let offerIdentifier = "offer_id"
let product = Fixtures.product(id: productIdOne,
title: "yearly",
duration: .year,
price: 29.99,
offerIdentifier: offerIdentifier)
let products = [
product,
Fixtures.product(id: productIdTwo, title: "monthly", duration: .month, price: 2.99)
]
let customerInfo = Fixtures.customerInfo(
subscriptions: [
Fixtures.Subscription(
id: productIdOne,
store: "app_store",
purchaseDate: purchaseDate,
expirationDate: expirationDateFirst
),
Fixtures.Subscription(
id: productIdTwo,
store: "app_store",
purchaseDate: purchaseDate,
expirationDate: expirationDateSecond
)
].shuffled(),
entitlements: [
Fixtures.Entitlement(
entitlementId: "premium",
productId: productIdOne,
purchaseDate: purchaseDate,
expirationDate: expirationDateFirst
)
]
)

let promotionalViewModel = MockPromotionalOfferViewModel()

let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen,
purchasesProvider: MockManageSubscriptionsPurchases(
customerInfo: customerInfo,
products: products
),
promotionalOfferViewModel: promotionalViewModel)

await viewModel.loadScreen()

let screen = try XCTUnwrap(viewModel.screen)
expect(viewModel.state) == .success

let pathWithPromotionalOffer = try XCTUnwrap(screen.paths.first { path in
if case .promotionalOffer = path.detail {
return true
}
return false
})

expect(promotionalViewModel.offerToLoadPromoFor).to(beNil())

await viewModel.determineFlow(for: pathWithPromotionalOffer)

let loadingPath = try XCTUnwrap(viewModel.loadingPath)
expect(loadingPath.id) == pathWithPromotionalOffer.id

expect(promotionalViewModel.offerToLoadPromoFor) == offerIdentifier
}

private func reformat(ISO8601Date: String) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
@@ -442,14 +518,18 @@ private class Fixtures {
title: String,
duration: SKProduct.PeriodUnit,
price: Decimal,
priceLocale: String = "en_US"
priceLocale: String = "en_US",
offerIdentifier: String? = nil
) -> StoreProduct {
// Using SK1 products because they can be mocked, but CustomerCenterViewModel
// works with generic `StoreProduct`s regardless of what they contain
let sk1Product = MockSK1Product(mockProductIdentifier: id, mockLocalizedTitle: title)
sk1Product.mockPrice = price
sk1Product.mockPriceLocale = Locale(identifier: priceLocale)
sk1Product.mockSubscriptionPeriod = SKProductSubscriptionPeriod(numberOfUnits: 1, unit: duration)
if let offerIdentifier = offerIdentifier {
sk1Product.mockDiscount = SKProductDiscount(identifier: offerIdentifier)
}
return StoreProduct(sk1Product: sk1Product)
}

@@ -579,6 +659,7 @@ private extension ManageSubscriptionsViewModelTests {

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private class MockSK1Product: SK1Product {

var mockProductIdentifier: String
var mockLocalizedTitle: String

@@ -641,16 +722,42 @@ private class MockSK1Product: SK1Product {
override var subscriptionPeriod: SKProductSubscriptionPeriod? {
return mockSubscriptionPeriod
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
fileprivate extension SKProductSubscriptionPeriod {

convenience init(numberOfUnits: Int,
unit: SK1Product.PeriodUnit) {
self.init()
self.setValue(numberOfUnits, forKey: "numberOfUnits")
self.setValue(unit.rawValue, forKey: "unit")
}

}

fileprivate extension SKProductDiscount {

convenience init(identifier: String) {
self.init()
self.setValue(identifier, forKey: "identifier")
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor
class MockPromotionalOfferViewModel: PromotionalOfferViewModel {

var offerToLoadPromoFor: String?

override func loadPromo(promotionalOfferId: String) async {
self.offerToLoadPromoFor = promotionalOfferId
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// FeedbackSurveyViewModelTests.swift
//
// Created by Cesar de la Vega on 17/7/24.

import Nimble
import RevenueCat
@testable import RevenueCatUI
import XCTest

#if os(iOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor
class PromotionalOfferViewModelTests: TestCase {

}

#endif
Original file line number Diff line number Diff line change
@@ -47,7 +47,10 @@ class CustomerCenterConfigDataTests: TestCase {
id: "path2",
title: "Path 2",
type: .cancel,
promotionalOffer: .init(iosOfferId: "offer_id", eligible: true),
promotionalOffer: .init(iosOfferId: "offer_id",
eligible: true,
title: "Wait!",
subtitle: "Before you go"),
feedbackSurvey: nil
),
.init(
@@ -60,7 +63,9 @@ class CustomerCenterConfigDataTests: TestCase {
.init(id: "id_1",
title: "option 1",
promotionalOffer: .init(iosOfferId: "offer_id_1",
eligible: true))
eligible: true,
title: "Wait!",
subtitle: "Before you go"))
])
)
]