Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI Preview Mode: mock products #4735

Merged
merged 7 commits into from
Jan 30, 2025
Merged
3 changes: 3 additions & 0 deletions Sources/Networking/Responses/OfferingsResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ extension OfferingsResponse {
)
}

var packages: [Offering.Package] {
return self.offerings.flatMap { $0.packages }
}
}

extension OfferingsResponse.Offering.Package: Codable, Equatable {}
Expand Down
141 changes: 140 additions & 1 deletion Sources/Purchasing/OfferingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ private extension OfferingsManager {
return
}

self.productsManager.products(withIdentifiers: productIdentifiers) { result in
self.fetchProducts(withIdentifiers: productIdentifiers, fromResponse: response) { result in
let products = result.value ?? []

guard products.isEmpty == false else {
Expand Down Expand Up @@ -275,6 +275,95 @@ private extension OfferingsManager {
}
}

private func fetchProducts(
withIdentifiers identifiers: Set<String>,
fromResponse response: OfferingsResponse,
completion: @escaping ProductsManagerType.Completion
) {
if self.systemInfo.dangerousSettings.uiPreviewMode {
let mockProducts = self.createMockProducts(productIdentifiers: identifiers, fromResponse: response)
completion(.success(mockProducts))
} else {
self.productsManager.products(withIdentifiers: identifiers, completion: completion)
}
}

// MARK: - Mocks for UI Preview mode

private func createMockProducts(
productIdentifiers: Set<String>,
fromResponse response: OfferingsResponse
) -> Set<StoreProduct> {
let packagesByProductID = response.packages.dictionaryAllowingDuplicateKeys { $0.platformProductIdentifier }
let products = productIdentifiers.map { identifier -> StoreProduct in
let productType = self.mockProductType(from: packagesByProductID[identifier],
productIdentifier: identifier)

let introductoryDiscount: TestStoreProductDiscount? = {
// For mocking purposes: all yearly subscriptions have a 1-week free trial
guard productType.period?.unit == .year else { return nil }
return TestStoreProductDiscount(
identifier: "intro",
price: 0,
localizedPriceString: "$0.00",
paymentMode: .freeTrial,
subscriptionPeriod: SubscriptionPeriod(value: 1, unit: .week),
numberOfPeriods: 1,
type: .introductory
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on @JayShortway's comment here, I'm only adding mocked introductory offers for yearly subscriptions

}()

let testProduct = TestStoreProduct(
localizedTitle: "PRO \(productType.type)",
price: Decimal(productType.price),
localizedPriceString: String(format: "$%.2f", productType.price),
productIdentifier: identifier,
productType: productType.period == nil ? .nonConsumable : .autoRenewableSubscription,
localizedDescription: "\(productType.type) subscription",
subscriptionGroupIdentifier: productType.period == nil ? nil : "group",
subscriptionPeriod: productType.period,
introductoryDiscount: introductoryDiscount,
discounts: []
)

return testProduct.toStoreProduct()
}

return Set(products)
}

private func mockProductType(
from package: OfferingsResponse.Offering.Package?,
productIdentifier: String
) -> ProductTypeMock {
if let package,
let mockProductType = ProductTypeMock(packageType: Package.packageType(from: package.identifier)) {
return mockProductType
} else {
// Try to guess basing on the product identifier
let id = productIdentifier.lowercased()

let packageType: PackageType
if id.contains("lifetime") || id.contains("forever") || id.contains("permanent") {
packageType = .lifetime
} else if id.contains("annual") || id.contains("year") {
packageType = .annual
} else if id.contains("sixmonth") || id.contains("6month") {
packageType = .sixMonth
} else if id.contains("threemonth") || id.contains("3month") || id.contains("quarter") {
packageType = .threeMonth
} else if id.contains("twomonth") || id.contains("2month") {
packageType = .twoMonth
} else if id.contains("month") {
packageType = .monthly
} else if id.contains("week") {
packageType = .weekly
} else {
packageType = .custom
}
return ProductTypeMock(packageType: packageType) ?? .default
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm wondering if we should default always to a lifetime in this case.... But then again, I don't have a better alternative, so I think this is ok for now!

}
}
}

extension OfferingsManager {
Expand Down Expand Up @@ -402,3 +491,53 @@ extension OfferingsManager.Error: CustomNSError {
}

}

/// For UI Preview mode only.
private struct ProductTypeMock {
let type: String
let price: Double
let period: SubscriptionPeriod?

static let `default` = ProductTypeMock(type: "lifetime", price: 249.99, period: nil)

private init(type: String, price: Double, period: SubscriptionPeriod?) {
self.type = type
self.price = price
self.period = period
}

init?(packageType: PackageType) {
switch packageType {
case .lifetime:
self = ProductTypeMock(type: "lifetime",
price: 199.99,
period: nil)
case .annual:
self = ProductTypeMock(type: "yearly",
price: 59.99,
period: SubscriptionPeriod(value: 1, unit: .year))
case .sixMonth:
self = ProductTypeMock(type: "6 months",
price: 30.99,
period: SubscriptionPeriod(value: 3, unit: .month))
case .threeMonth:
self = ProductTypeMock(type: "3 months",
price: 15.99,
period: SubscriptionPeriod(value: 3, unit: .month))
case .twoMonth:
self = ProductTypeMock(type: "monthly",
price: 11.49,
period: SubscriptionPeriod(value: 2, unit: .month))
case .monthly:
self = ProductTypeMock(type: "monthly",
price: 5.99,
period: SubscriptionPeriod(value: 1, unit: .month))
case .weekly:
self = ProductTypeMock(type: "weekly",
price: 1.99,
period: SubscriptionPeriod(value: 1, unit: .week))
case .unknown, .custom:
return nil
}
}
}
65 changes: 64 additions & 1 deletion Tests/UnitTests/Purchasing/OfferingsManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// Created by Juanpe Catalán on 9/8/21.

import Nimble
@testable import RevenueCat
@testable @_spi(Internal) import RevenueCat
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat 💅

import StoreKit
import XCTest

Expand Down Expand Up @@ -478,6 +478,69 @@ extension OfferingsManagerTests {
level: .error)
}

func testProductsManagerIsNotUsedInUIPreviewModeWhenGetOfferingsSuccess() throws {
// given
let mockSystemInfoWithPreviewMode = MockSystemInfo(
platformInfo: .init(flavor: "iOS", version: "3.2.1"),
finishTransactions: true,
dangerousSettings: DangerousSettings(uiPreviewMode: true)
)

self.offeringsManager = OfferingsManager(
deviceCache: self.mockDeviceCache,
operationDispatcher: self.mockOperationDispatcher,
systemInfo: mockSystemInfoWithPreviewMode,
backend: self.mockBackend,
offeringsFactory: self.mockOfferingsFactory,
productsManager: self.mockProductsManager
)

self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsResponse)

// when
let result: Result<Offerings, OfferingsManager.Error>? = waitUntilValue { completed in
self.offeringsManager.offerings(appUserID: MockData.anyAppUserID) { result in
completed(result)
}
}

// then
expect(result).to(beSuccess())
expect(self.mockProductsManager.invokedProducts) == false
expect(result?.value?.current?.availablePackages).toNot(beEmpty())
}

func testProductsManagerIsNotUsedInUIPreviewModeWhenGetOfferingsFailure() throws {
// given
let mockSystemInfoWithPreviewMode = MockSystemInfo(
platformInfo: .init(flavor: "iOS", version: "3.2.1"),
finishTransactions: true,
dangerousSettings: DangerousSettings(uiPreviewMode: true)
)

self.offeringsManager = OfferingsManager(
deviceCache: self.mockDeviceCache,
operationDispatcher: self.mockOperationDispatcher,
systemInfo: mockSystemInfoWithPreviewMode,
backend: self.mockBackend,
offeringsFactory: self.mockOfferingsFactory,
productsManager: self.mockProductsManager
)

self.mockOfferings.stubbedGetOfferingsCompletionResult = .failure(MockData.unexpectedBackendResponseError)

// when
let result: Result<Offerings, OfferingsManager.Error>? = waitUntilValue { completed in
self.offeringsManager.offerings(appUserID: MockData.anyAppUserID) { result in
completed(result)
}
}

// then
expect(result).to(beFailure())
expect(self.mockProductsManager.invokedProducts) == false
}

}

private extension OfferingsManagerTests {
Expand Down