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

Fixed purchasing with PromotionalOffers using StoreKit 2 #2020

Merged
merged 2 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/Error Handling/ErrorUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,11 @@ enum ErrorUtils {
*/
static func invalidPromotionalOfferError(
error: Error? = nil,
message: String? = nil,
fileName: String = #fileID, functionName: String = #function, line: UInt = #line
) -> PurchasesError {
return ErrorUtils.error(with: .invalidPromotionalOfferError,
message: message,
underlyingError: error)
}

Expand Down
5 changes: 4 additions & 1 deletion Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ final class PurchasesOrchestrator {
Logger.debug(
Strings.storeKit.sk2_purchasing_added_promotional_offer_option(signedData.identifier)
)
options.insert(signedData.sk2PurchaseOption)
options.insert(try signedData.sk2PurchaseOption)
}

return try await sk2Product.purchase(options: options)
Expand All @@ -446,6 +446,9 @@ final class PurchasesOrchestrator {
fetchPolicy: .cachedOrFetched),
userCancelled: true
)
} catch let error as PromotionalOffer.SignedData.Error {
throw ErrorUtils.invalidPromotionalOfferError(error: error,
message: error.localizedDescription)
} catch {
throw ErrorUtils.purchasesError(withStoreKitError: error)
}
Expand Down
44 changes: 37 additions & 7 deletions Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ extension PromotionalOffer.SignedData: @unchecked Sendable {}

extension PromotionalOffer.SignedData {

enum Error: Swift.Error {

/// The signature generated by the backend could not be decoded.
case failedToDecodeSignature(String)

}

@available(iOS 12.2, macOS 10.14.4, watchOS 6.2, macCatalyst 13.0, tvOS 12.2, *)
convenience init(sk1PaymentDiscount discount: SKPaymentDiscount) {
self.init(identifier: discount.identifier,
Expand All @@ -122,13 +129,36 @@ extension PromotionalOffer.SignedData {

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
var sk2PurchaseOption: Product.PurchaseOption {
return .promotionalOffer(
offerID: self.identifier,
keyID: self.keyIdentifier,
nonce: self.nonce,
signature: self.signature.asData,
timestamp: self.timestamp
)
get throws {
let signature: Data

if let decoded = Data(base64Encoded: self.signature) {
signature = decoded
} else {
throw Error.failedToDecodeSignature(self.signature)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I improved error handling here (the last TODO) and added a few tests.

}

return .promotionalOffer(
offerID: self.identifier,
keyID: self.keyIdentifier,
nonce: self.nonce,
signature: signature,
timestamp: self.timestamp
)
}
}

}

// MARK: -

extension PromotionalOffer.SignedData.Error: LocalizedError {

var errorDescription: String? {
switch self {
case let .failedToDecodeSignature(signature):
return "The signature generated by RevenueCat could not be decoded: \(signature)"
}
}

}
2 changes: 0 additions & 2 deletions Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,6 @@ class StoreKit1IntegrationTests: BaseBackendIntegrationTests {
@available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *)
func testPurchaseWithPromotionalOffer() async throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
try XCTSkipIf(Self.storeKit2Setting == .enabledForCompatibleDevices,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🎉

"This test is not currently passing on SK2")

let user = UUID().uuidString

Expand Down
27 changes: 27 additions & 0 deletions Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,33 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase {
}
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testPurchaseSK2PackageWithInvalidPromotionalOfferSignatureThrowsError() async throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo
self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo)

let product = try await self.fetchSk2Product()
let offer = PromotionalOffer.SignedData(
identifier: "identifier \(Int.random(in: 0..<1000))",
keyIdentifier: "key identifier \(Int.random(in: 0..<1000))",
nonce: .init(),
// This should be base64
signature: "signature \(Int.random(in: 0..<1000))",
Comment on lines +482 to +483
Copy link
Member

Choose a reason for hiding this comment

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

why not base64 the result of this so it looks realistic?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is

testPurchaseSK2PackageWithInvalidPromotionalOfferSignatureThrowsError

So I'm testing that if it's not base64 (so it's just a random string), it produces the correct error instead of crashing or something.

timestamp: Int.random(in: 0..<1000)
)

do {
_ = try await orchestrator.purchase(sk2Product: product, promotionalOffer: offer)
XCTFail("Expected error")
} catch {
expect(error).to(matchError(ErrorCode.invalidPromotionalOfferError))
expect(error.localizedDescription)
.to(contain("The signature generated by RevenueCat could not be decoded: \(offer.signature)"))
}
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testPurchaseSK2PackageReturnsCustomerInfoForFailedTransaction() async throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,50 @@ class PromotionalOfferTests: TestCase {
func testSK2PurchaseOption() throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

let option = Self.randomOffer.sk2PurchaseOption
let option = try Self.randomOffer.sk2PurchaseOption
let expected: Product.PurchaseOption = .promotionalOffer(
offerID: Self.randomOffer.identifier,
keyID: Self.randomOffer.keyIdentifier,
nonce: Self.randomOffer.nonce,
signature: Self.randomOffer.signature.asData,
// `Product.PurchaseOption` conforms to Equatable but it does not compare this
// The only way to validate this is correct is integration tests.
signature: Data(),
timestamp: Self.randomOffer.timestamp
)

expect(option) == expected
}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func testSK2PurchaseOptionWithInvalidSignatureThrows() throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

do {
_ = try Self.invalidOffer.sk2PurchaseOption
fail("Expected error")
} catch {
expect(error).to(matchError(
PromotionalOffer.SignedData.Error
.failedToDecodeSignature(Self.invalidOffer.signature)
))
}
}

}

// MARK: - Private

private extension PromotionalOfferTests {

static let randomOffer: PromotionalOffer.SignedData = .init(
identifier: "identifier \(Int.random(in: 0..<1000))",
keyIdentifier: "key identifier \(Int.random(in: 0..<1000))",
nonce: .init(),
signature: "signature \(Int.random(in: 0..<1000))".asData.base64EncodedString(),
timestamp: Int.random(in: 0..<1000)
)

static let invalidOffer: PromotionalOffer.SignedData = .init(
identifier: "identifier \(Int.random(in: 0..<1000))",
keyIdentifier: "key identifier \(Int.random(in: 0..<1000))",
nonce: .init(),
Expand Down