Skip to content

Commit

Permalink
OfferingsManager: expose underlying error when ProductsManager re…
Browse files Browse the repository at this point in the history
…turns an error (#1792)

This will help figure out the reason behind #1765.
We were currently ignoring the error returned by `ProductsManager`.

In the example below, we can see the underlying issue is `SKErrorDomain` code 5.

### Before:
> Error fetching offerings - The operation couldn’t be completed. (RevenueCat.OfferingsManager.Error error 1.)

### After:
> Error fetching offerings - The operation couldn’t be completed. (RevenueCat.OfferingsManager.Error error 1.)
_**Underlying error: The operation couldn’t be completed. (SKErrorDomain error 5.)**_
ERROR: 😿‼️ There is an issue with your configuration. Check the underlying error for more details. There's a problem with your configuration. None of the products registered in the RevenueCat dashboard could be fetched from App Store Connect (or the StoreKit Configuration file if one is being used). 
More information: https://rev.cat/why-are-offerings-empty
  • Loading branch information
NachoSoto authored Aug 2, 2022
1 parent 33840f6 commit 233e3a3
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 44 deletions.
6 changes: 4 additions & 2 deletions Sources/Error Handling/ErrorUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import Foundation
import StoreKit

// swiftlint:disable file_length multiline_parameters
// swiftlint:disable file_length multiline_parameters type_body_length

enum ErrorUtils {

Expand Down Expand Up @@ -218,9 +218,11 @@ enum ErrorUtils {
*/
static func configurationError(
message: String? = nil,
underlyingError: Error? = nil,
fileName: String = #fileID, functionName: String = #function, line: UInt = #line
) -> Error {
return error(with: ErrorCode.configurationError, message: message,
return error(with: ErrorCode.configurationError,
message: message, underlyingError: underlyingError,
fileName: fileName, functionName: functionName, line: line)
}

Expand Down
13 changes: 9 additions & 4 deletions Sources/Logging/Strings/OfferingStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import StoreKit
enum OfferingStrings {

case cannot_find_product_configuration_error(identifiers: Set<String>)
case fetching_offerings_error(error: String)
case fetching_offerings_error(error: OfferingsManager.Error, underlyingError: Error?)
case found_existing_product_request(identifiers: Set<String>)
case no_cached_offerings_fetching_from_network
case offerings_stale_updated_from_network
Expand All @@ -45,14 +45,19 @@ extension OfferingStrings: CustomStringConvertible {

var description: String {
switch self {

case .cannot_find_product_configuration_error(let identifiers):
return "Could not find SKProduct for \(identifiers) " +
"\nThere is a problem with your configuration in App Store Connect. " +
"\nMore info here: https://errors.rev.cat/configuring-products"

case .fetching_offerings_error(let error):
return "Error fetching offerings - \(error)"
case let .fetching_offerings_error(error, underlyingError):
var result = "Error fetching offerings - \(error.localizedDescription)"

if let underlyingError = underlyingError {
result += "\nUnderlying error: \(underlyingError.localizedDescription)"
}

return result

case .found_existing_product_request(let identifiers):
return "Found an existing request for products: \(identifiers), appending " +
Expand Down
39 changes: 31 additions & 8 deletions Sources/Purchasing/OfferingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private extension OfferingsManager {

guard !productIdentifiers.isEmpty else {
let errorMessage = Strings.offering.configuration_error_no_products_for_offering.description
self.handleOfferingsUpdateError(.configurationError(errorMessage),
self.handleOfferingsUpdateError(.configurationError(errorMessage, underlyingError: nil),
completion: completion)
return
}
Expand All @@ -124,9 +124,11 @@ private extension OfferingsManager {
let products = result.value ?? []

guard products.isEmpty == false else {
let errorMessage = Strings.offering.configuration_error_skproducts_not_found.description
self.handleOfferingsUpdateError(.configurationError(errorMessage),
completion: completion)
self.handleOfferingsUpdateError(
.configurationError(Strings.offering.configuration_error_skproducts_not_found.description,
underlyingError: result.error as NSError?),
completion: completion
)
return
}

Expand All @@ -150,7 +152,8 @@ private extension OfferingsManager {
}

func handleOfferingsUpdateError(_ error: Error, completion: ((Result<Offerings, Error>) -> Void)?) {
Logger.appleError(Strings.offering.fetching_offerings_error(error: error.localizedDescription))
Logger.appleError(Strings.offering.fetching_offerings_error(error: error,
underlyingError: error.underlyingError))
deviceCache.clearOfferingsCacheTimestamp()
dispatchCompletionOnMainThreadIfPossible(completion, result: .failure(error))
}
Expand All @@ -171,7 +174,7 @@ extension OfferingsManager {
enum Error: Swift.Error, Equatable {

case backendError(BackendError)
case configurationError(String, ErrorSource)
case configurationError(String, NSError?, ErrorSource)
case noOfferingsFound(ErrorSource)

}
Expand All @@ -185,8 +188,9 @@ extension OfferingsManager.Error: ErrorCodeConvertible {
case let .backendError(backendError):
return backendError.asPurchasesError

case let .configurationError(errorMessage, source):
case let .configurationError(errorMessage, underlyingError, source):
return ErrorUtils.configurationError(message: errorMessage,
underlyingError: underlyingError,
fileName: source.file,
functionName: source.function,
line: source.line)
Expand All @@ -200,11 +204,12 @@ extension OfferingsManager.Error: ErrorCodeConvertible {

static func configurationError(
_ errorMessage: String,
underlyingError: NSError?,
file: String = #fileID,
function: String = #function,
line: UInt = #line
) -> Self {
return .configurationError(errorMessage, .init(file: file, function: function, line: line))
return .configurationError(errorMessage, underlyingError, .init(file: file, function: function, line: line))
}

static func noOfferingsFound(
Expand All @@ -216,3 +221,21 @@ extension OfferingsManager.Error: ErrorCodeConvertible {
}

}

extension OfferingsManager.Error: CustomNSError {

var errorUserInfo: [String: Any] {
return [
NSUnderlyingErrorKey: self.underlyingError as NSError? as Any
]
}

fileprivate var underlyingError: Error? {
switch self {
case let .backendError(error): return error
case let .configurationError(_, error, _): return error
case .noOfferingsFound: return nil
}
}

}
4 changes: 2 additions & 2 deletions Tests/StoreKitUnitTests/OfferingsManagerStoreKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ extension OfferingsManagerStoreKitTests {
mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsResponse)
var fetchedStoreProduct = try await fetchSk2StoreProduct()
var storeProduct = StoreProduct(sk2Product: fetchedStoreProduct.underlyingSK2Product)
mockProductsManager.stubbedProductsCompletionResult = Set([storeProduct])
mockProductsManager.stubbedProductsCompletionResult = .success(Set([storeProduct]))

var receivedOfferings = try await offeringsManager.offerings(appUserID: MockData.anyAppUserID)
var receivedProduct = try XCTUnwrap(receivedOfferings.current?.availablePackages.first?.storeProduct)
Expand All @@ -66,7 +66,7 @@ extension OfferingsManagerStoreKitTests {

fetchedStoreProduct = try await fetchSk2StoreProduct()
storeProduct = StoreProduct(sk2Product: fetchedStoreProduct.underlyingSK2Product)
mockProductsManager.stubbedProductsCompletionResult = Set([storeProduct])
mockProductsManager.stubbedProductsCompletionResult = .success(Set([storeProduct]))

// Note: this test passes only because the method `invalidateAndReFetchCachedOfferingsIfAppropiate`
// is manually executed. `OfferingsManager` does not detect Storefront changes to invalidate the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase {

var finalResults: [String: IntroEligibility] = [:]

self.mockProductsManager.stubbedProductsCompletionResult = Set(storeProducts)
self.mockProductsManager.stubbedProductsCompletionResult = .success(Set(storeProducts))
trialOrIntroPriceEligibilityChecker.productsWithKnownIntroEligibilityStatus(
productIdentifiers: productIdentifiers) { results in
finalResults = results
Expand All @@ -114,7 +114,7 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase {
}

func testSK1EligibilityIsFetchedFromBackendIfErrorCalculatingEligibilityAndStoreKitDoesNotHaveIt() throws {
self.mockProductsManager.stubbedProductsCompletionResult = Set()
self.mockProductsManager.stubbedProductsCompletionResult = .success([])
let stubbedError = NSError(domain: RCPurchasesErrorCodeDomain,
code: ErrorCode.invalidAppUserIdError.rawValue,
userInfo: [:])
Expand Down Expand Up @@ -148,9 +148,9 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase {
sk1Product.mockDiscount = nil
let storeProduct = StoreProduct(sk1Product: sk1Product)

self.mockProductsManager.stubbedProductsCompletionResult = Set(
[storeProduct]
)
self.mockProductsManager.stubbedProductsCompletionResult = .success([
storeProduct
])

let productId = "product_id"
let stubbedEligibility = [productId: IntroEligibility(eligibilityStatus: IntroEligibilityStatus.eligible)]
Expand All @@ -171,7 +171,7 @@ class TrialOrIntroPriceEligibilityCheckerSK1Tests: StoreKitConfigTestCase {
}

func testSK1ErrorFetchingFromBackendAfterErrorCalculatingEligibility() throws {
self.mockProductsManager.stubbedProductsCompletionResult = Set()
self.mockProductsManager.stubbedProductsCompletionResult = .success([])
let productId = "product_id"

let stubbedError: BackendError = .networkError(
Expand Down
11 changes: 6 additions & 5 deletions Tests/UnitTests/Mocks/MockProductsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ class MockProductsManager: ProductsManager {
var invokedProductsCount = 0
var invokedProductsParameters: Set<String>?
var invokedProductsParametersList = [(identifiers: Set<String>, Void)]()
var stubbedProductsCompletionResult: Set<StoreProduct>?
var stubbedProductsCompletionResult: Result<Set<StoreProduct>, Error>?

override func products(withIdentifiers identifiers: Set<String>,
completion: @escaping (Result<Set<StoreProduct>, Error>) -> Void) {
invokedProducts = true
invokedProductsCount += 1
invokedProductsParameters = identifiers
invokedProductsParametersList.append((identifiers, ()))
if let result = stubbedProductsCompletionResult {
completion(.success(result))
if let result = self.stubbedProductsCompletionResult {
completion(result)
} else {
let products: [StoreProduct] = identifiers
.map { (identifier) -> MockSK1Product in
Expand All @@ -44,12 +44,13 @@ class MockProductsManager: ProductsManager {
@available(iOS 13.0, tvOS 13.0, watchOS 6.2, macOS 10.15, *)
override func products(
withIdentifiers identifiers: Set<String>
) async -> Set<StoreProduct> {
) async throws -> Set<StoreProduct> {
invokedProducts = true
invokedProductsCount += 1
invokedProductsParameters = identifiers
invokedProductsParametersList.append((identifiers, ()))
return stubbedProductsCompletionResult ?? Set()

return try self.stubbedProductsCompletionResult?.get() ?? []
}

var invokedCacheProduct = false
Expand Down
19 changes: 10 additions & 9 deletions Tests/UnitTests/Purchasing/IntroEligibilityCalculatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ class IntroEligibilityCalculatorTests: TestCase {
mockReceiptParser.stubbedParseResult = receipt
let receiptIdentifiers = receipt.purchasedIntroOfferOrFreeTrialProductIdentifiers()

mockProductsManager.stubbedProductsCompletionResult = Set(
["a", "b"]
.map { MockSK1Product(mockProductIdentifier: $0) }
.map(StoreProduct.init(sk1Product:))
mockProductsManager.stubbedProductsCompletionResult = .success(
Set(
["a", "b"]
.map { MockSK1Product(mockProductIdentifier: $0) }
.map(StoreProduct.init(sk1Product:))
)
)

let candidateIdentifiers = Set(["a", "b", "c"])
Expand Down Expand Up @@ -98,9 +100,8 @@ class IntroEligibilityCalculatorTests: TestCase {
mockSubscriptionGroupIdentifier: "group2")
product2.mockDiscount = MockSKProductDiscount()

mockProductsManager.stubbedProductsCompletionResult = Set(
[product1, product2]
.map(StoreProduct.init(sk1Product:))
mockProductsManager.stubbedProductsCompletionResult = .success(
Set([product1, product2].map(StoreProduct.init(sk1Product:)))
)

let candidateIdentifiers = Set(["com.revenuecat.product1",
Expand Down Expand Up @@ -133,7 +134,7 @@ class IntroEligibilityCalculatorTests: TestCase {
let mockProduct = MockSK1Product(mockProductIdentifier: "com.revenuecat.product1",
mockSubscriptionGroupIdentifier: "group1")
mockProduct.mockDiscount = nil
mockProductsManager.stubbedProductsCompletionResult = Set([StoreProduct(sk1Product: mockProduct)])
mockProductsManager.stubbedProductsCompletionResult = .success([StoreProduct(sk1Product: mockProduct)])

let candidateIdentifiers = Set(["com.revenuecat.product1"])

Expand Down Expand Up @@ -162,7 +163,7 @@ class IntroEligibilityCalculatorTests: TestCase {
mockSubscriptionGroupIdentifier: "group1")
mockProduct.mockDiscount = nil
mockProduct.mockSubscriptionPeriod = nil
mockProductsManager.stubbedProductsCompletionResult = Set([StoreProduct(sk1Product: mockProduct)])
mockProductsManager.stubbedProductsCompletionResult = .success([StoreProduct(sk1Product: mockProduct)])

let candidateIdentifiers = Set(["lifetime"])

Expand Down
50 changes: 43 additions & 7 deletions Tests/UnitTests/Purchasing/OfferingsManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,20 @@ extension OfferingsManagerTests {
// then
expect(result).toEventuallyNot(beNil())
expect(result).to(beFailure())
expect(result?.error) == .configurationError(
Strings.offering.configuration_error_no_products_for_offering.description
)

switch result?.error {
case let .configurationError(message, underlyingError, _):
expect(message) == Strings.offering.configuration_error_no_products_for_offering.description
expect(underlyingError).to(beNil())
default:
fail("Unexpected result")
}
}

func testOfferingsForAppUserIDReturnsConfigurationErrorIfProductsRequestsReturnsEmpty() throws {
// given
mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsResponse)
mockProductsManager.stubbedProductsCompletionResult = Set()
mockProductsManager.stubbedProductsCompletionResult = .success(Set())

// when
var result: Result<Offerings, OfferingsManager.Error>?
Expand All @@ -137,9 +142,40 @@ extension OfferingsManagerTests {
// then
expect(result).toEventuallyNot(beNil())
expect(result).to(beFailure())
expect(result?.error) == .configurationError(
Strings.offering.configuration_error_skproducts_not_found.description
)

switch result?.error {
case let .configurationError(message, underlyingError, _):
expect(message) == Strings.offering.configuration_error_skproducts_not_found.description
expect(underlyingError).to(beNil())
default:
fail("Unexpected result")
}
}

func testOfferingsForAppUserIDReturnsConfigurationErrorIfProductsRequestsReturnsError() throws {
let error: Error = NSError(domain: SKErrorDomain, code: SKError.Code.storeProductNotAvailable.rawValue)

// given
mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsResponse)
mockProductsManager.stubbedProductsCompletionResult = .failure(error)

// when
var result: Result<Offerings, OfferingsManager.Error>?
offeringsManager.offerings(appUserID: MockData.anyAppUserID) {
result = $0
}

// then
expect(result).toEventuallyNot(beNil())
expect(result).to(beFailure())

switch result?.error {
case let .configurationError(message, underlyingError, _):
expect(message) == Strings.offering.configuration_error_skproducts_not_found.description
expect(underlyingError).to(matchError(error))
default:
fail("Unexpected result")
}
}

func testOfferingsForAppUserIDReturnsUnexpectedBackendResponseIfOfferingsFactoryCantCreateOfferings() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ class PurchasesTransactionHandlingTests: BasePurchasesTests {
}

func testProductIsRemovedButPresentInTheQueuedTransaction() throws {
self.mockProductsManager.stubbedProductsCompletionResult = []
self.mockProductsManager.stubbedProductsCompletionResult = .success([])

let customerInfoBeforePurchase = try CustomerInfo(data: [
"request_date": "2019-08-16T10:30:42Z",
Expand Down

0 comments on commit 233e3a3

Please sign in to comment.