mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
(Internal) Spend virtual currencies (#7507)
This commit is contained in:
1 parent
2dbb74e59a
commit
a1781cb28e
18 files changed
+910
-9
No files matched your search
@@ -625,8 +625,10 @@
|
||||
55DACE97302E320C005EE017 /* MockTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DACE96302E320C005EE017 /* MockTokenManager.swift */; };
|
||||
55DACE98302E320C005EE017 /* MockTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DACE96302E320C005EE017 /* MockTokenManager.swift */; };
|
||||
55DACEAC302E837B005EE017 /* TokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DACEAB302E837B005EE017 /* TokenManagerTests.swift */; };
|
||||
55DACFD230366629005EE017 /* AuthenticationStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DACFCC30366625005EE017 /* AuthenticationStrings.swift */; };
|
||||
55DACF333034FA93005EE017 /* MockTokenAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3DB72A2707F75F2BDC90D7 /* MockTokenAPI.swift */; };
|
||||
55DACFD230366629005EE017 /* AuthenticationStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DACFCC30366625005EE017 /* AuthenticationStrings.swift */; };
|
||||
55DAD04D30377F26005EE017 /* SpendVirtualCurrenciesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DAD04730377F20005EE017 /* SpendVirtualCurrenciesOperation.swift */; };
|
||||
55DAD16A303E5269005EE017 /* SpendVirtualCurrenciesOperationBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DAD169303E5269005EE017 /* SpendVirtualCurrenciesOperationBodyTests.swift */; };
|
||||
57045B3829C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */; };
|
||||
57045B3A29C51751001A5417 /* GetProductEntitlementMappingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */; };
|
||||
57045B3C29C51AF7001A5417 /* ProductEntitlementMappingCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045B3B29C51AF7001A5417 /* ProductEntitlementMappingCallback.swift */; };
|
||||
@@ -2364,6 +2366,8 @@
|
||||
55DACE96302E320C005EE017 /* MockTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTokenManager.swift; sourceTree = "<group>"; };
|
||||
55DACEAB302E837B005EE017 /* TokenManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerTests.swift; sourceTree = "<group>"; };
|
||||
55DACFCC30366625005EE017 /* AuthenticationStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStrings.swift; sourceTree = "<group>"; };
|
||||
55DAD04730377F20005EE017 /* SpendVirtualCurrenciesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpendVirtualCurrenciesOperation.swift; sourceTree = "<group>"; };
|
||||
55DAD169303E5269005EE017 /* SpendVirtualCurrenciesOperationBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpendVirtualCurrenciesOperationBodyTests.swift; sourceTree = "<group>"; };
|
||||
55FCA85B959807F2D3DD175A /* TransactionReason.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionReason.swift; sourceTree = "<group>"; };
|
||||
57045B3729C514A8001A5417 /* ProductEntitlementMappingDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductEntitlementMappingDecodingTests.swift; sourceTree = "<group>"; };
|
||||
57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProductEntitlementMappingOperation.swift; sourceTree = "<group>"; };
|
||||
@@ -5903,6 +5907,7 @@
|
||||
children = (
|
||||
75D9DE042D79FC0E0068554F /* __Snapshots__ */,
|
||||
75D9DE052D79FC0E0068554F /* DiagnosticsEventEncodingTests.swift */,
|
||||
55DAD169303E5269005EE017 /* SpendVirtualCurrenciesOperationBodyTests.swift */,
|
||||
);
|
||||
path = Requests;
|
||||
sourceTree = "<group>";
|
||||
@@ -6727,6 +6732,7 @@
|
||||
B34605B4279A6E380031CA74 /* GetIntroEligibilityOperation.swift */,
|
||||
B34605BA279A6E380031CA74 /* GetOfferingsOperation.swift */,
|
||||
FD18BF4D2DF7670200140FD6 /* GetVirtualCurrenciesOperation.swift */,
|
||||
55DAD04730377F20005EE017 /* SpendVirtualCurrenciesOperation.swift */,
|
||||
75B6DFFA2E30EC86009F0A99 /* GetWebOfferingProductsOperation.swift */,
|
||||
75A9F25B2E38DE57005D1AB1 /* GetWebBillingProductsOperation.swift */,
|
||||
57045B3929C51751001A5417 /* GetProductEntitlementMappingOperation.swift */,
|
||||
@@ -7667,6 +7673,7 @@
|
||||
F5C0196926E880800005D61E /* StoreKitStrings.swift in Sources */,
|
||||
2D4E926526990AB1000E10B0 /* StoreKit1Wrapper.swift in Sources */,
|
||||
2DDF419624F6F331005BC22D /* ProductsRequestFactory.swift in Sources */,
|
||||
55DAD04D30377F26005EE017 /* SpendVirtualCurrenciesOperation.swift in Sources */,
|
||||
1E8A60422CDBD75A0034ACF3 /* WebPurchaseRedemption.swift in Sources */,
|
||||
032B13C82E0263750099E1ED /* SubscriptionHistoryTracker.swift in Sources */,
|
||||
2DDF419724F6F331005BC22D /* DateExtensions.swift in Sources */,
|
||||
@@ -8171,6 +8178,7 @@
|
||||
5766AA5A283D4CAB00FA6091 /* IgnoreHashableTests.swift in Sources */,
|
||||
0368727A2D9DCF7100506BBB /* DefaultEnumTests.swift in Sources */,
|
||||
E6A54710537E4AA3810E1CB0 /* BackendGetRemoteConfigTests.swift in Sources */,
|
||||
55DAD16A303E5269005EE017 /* SpendVirtualCurrenciesOperationBodyTests.swift in Sources */,
|
||||
FACE0000000000000000A001 /* BackendRemoteConfigLaneTests.swift in Sources */,
|
||||
03C7305B2D35985900297FEC /* TextComponentTests.swift in Sources */,
|
||||
FDE57B0E2DF8BC1100101CE2 /* VirtualCurrenciesDecodingTests.swift in Sources */,
|
||||
|
||||
@@ -50,10 +50,14 @@ enum BackendErrorCode: Int, Error {
|
||||
case invalidWebRedemptionToken = 7849
|
||||
case purchaseBelongsToOtherUser = 7852
|
||||
case expiredWebRedemptionToken = 7853
|
||||
case unknownVirtualCurrencyCode = 7870
|
||||
|
||||
case invalidIAMToken = 7981
|
||||
case cannotAliasToAuthenticatedUser = 8077
|
||||
|
||||
case duplicateVirtualCurrencyTransaction = 8139
|
||||
case invalidIdempotencyKey = 8140
|
||||
|
||||
/**
|
||||
* - Parameter code: Generally comes from the backend in json. This may be a String, or an Int, or nothing.
|
||||
*/
|
||||
@@ -110,7 +114,10 @@ extension BackendErrorCode {
|
||||
.invalidIAMToken:
|
||||
return .invalidCredentialsError
|
||||
case .invalidPaymentModeOrIntroPriceNotProvided,
|
||||
.productIdForGoogleReceiptNotProvided:
|
||||
.productIdForGoogleReceiptNotProvided,
|
||||
.unknownVirtualCurrencyCode,
|
||||
.duplicateVirtualCurrencyTransaction,
|
||||
.invalidIdempotencyKey:
|
||||
return .purchaseInvalidError
|
||||
case .emptyAppUserId,
|
||||
.invalidAppUserId:
|
||||
|
||||
@@ -22,6 +22,12 @@ enum VirtualCurrencyStrings {
|
||||
case virtual_currencies_stale_updating_from_network
|
||||
case virtual_currencies_updated_from_network
|
||||
case virtual_currencies_updated_from_network_error(Error)
|
||||
case empty_spend_amount
|
||||
case invalid_code
|
||||
case invalid_spend_amount(String, Int)
|
||||
case too_many_codes
|
||||
case virtual_currencies_spent_on_network
|
||||
case virtual_currencies_spent_on_network_error(Error)
|
||||
case error_decoding_cached_virtual_currencies(Error)
|
||||
|
||||
}
|
||||
@@ -41,6 +47,19 @@ extension VirtualCurrencyStrings: LogMessage {
|
||||
return "VirtualCurrencies updated from the network."
|
||||
case let .virtual_currencies_updated_from_network_error(error):
|
||||
return "Attempt to update VirtualCurrencies from network failed.\n\(error.localizedDescription)"
|
||||
case .empty_spend_amount:
|
||||
return "No amount specified to spend. This will be ignored."
|
||||
case .invalid_code:
|
||||
return "Invalid Virtual Currency code"
|
||||
case let .invalid_spend_amount(code, amount):
|
||||
return "Attempt to spend zero/negative currency amount \(amount) for \(code). " +
|
||||
"This is not allowed and will be ignored."
|
||||
case .too_many_codes:
|
||||
return "You may not specify this many codes in a single spend transaction."
|
||||
case .virtual_currencies_spent_on_network:
|
||||
return "VirtualCurrencies spent on the network."
|
||||
case let .virtual_currencies_spent_on_network_error(error):
|
||||
return "Attempt to spend VirtualCurrencies on network failed.\n\(error.localizedDescription)"
|
||||
case let .error_decoding_cached_virtual_currencies(error):
|
||||
return "Couldn't decode cached VirtualCurrencies:\n\(error)"
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ extension HTTPRequest {
|
||||
case getProductEntitlementMapping
|
||||
case getCustomerCenterConfig(appUserID: String)
|
||||
case getVirtualCurrencies(appUserID: String)
|
||||
case spendVirtualCurrencies
|
||||
case postRedeemWebPurchase
|
||||
case postCreateTicket
|
||||
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
|
||||
@@ -284,6 +285,7 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
.getProductEntitlementMapping,
|
||||
.getCustomerCenterConfig,
|
||||
.getVirtualCurrencies,
|
||||
.spendVirtualCurrencies,
|
||||
.appHealthReport,
|
||||
.postCreateTicket,
|
||||
.isPurchaseAllowedByRestoreBehavior,
|
||||
@@ -315,6 +317,7 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
.getProductEntitlementMapping,
|
||||
.getCustomerCenterConfig,
|
||||
.getVirtualCurrencies,
|
||||
.spendVirtualCurrencies,
|
||||
.appHealthReport,
|
||||
.postCreateTicket,
|
||||
.isPurchaseAllowedByRestoreBehavior,
|
||||
@@ -339,6 +342,7 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
.getOfferings,
|
||||
.getProductEntitlementMapping,
|
||||
.getVirtualCurrencies,
|
||||
.spendVirtualCurrencies,
|
||||
.appHealthReport,
|
||||
.appHealthReportAvailability,
|
||||
.isPurchaseAllowedByRestoreBehavior,
|
||||
@@ -366,6 +370,7 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
.logIn,
|
||||
.postReceiptData,
|
||||
.getVirtualCurrencies,
|
||||
.spendVirtualCurrencies,
|
||||
.health,
|
||||
.appHealthReportAvailability,
|
||||
.isPurchaseAllowedByRestoreBehavior,
|
||||
@@ -461,6 +466,11 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
case let .getVirtualCurrencies(appUserID):
|
||||
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
|
||||
|
||||
case .spendVirtualCurrencies:
|
||||
assertionFailure("The .spendVirtualCurrencies endpoint is only allowed when IAM is enabled")
|
||||
Logger.error("The .spendVirtualCurrencies endpoint is only allowed when IAM is enabled")
|
||||
return "customer/virtual_currencies/spend"
|
||||
|
||||
case .postCreateTicket:
|
||||
return "customercenter/support/create-ticket"
|
||||
case let .isPurchaseAllowedByRestoreBehavior(appUserID):
|
||||
@@ -520,6 +530,8 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
return "customer/customercenter"
|
||||
case .getVirtualCurrencies:
|
||||
return "customer/virtual_currencies"
|
||||
case .spendVirtualCurrencies:
|
||||
return "customer/virtual_currencies/spend"
|
||||
case .postRedeemWebPurchase:
|
||||
return self.pathComponent
|
||||
case .postCreateTicket:
|
||||
@@ -586,6 +598,9 @@ extension HTTPRequest.Path: HTTPRequestPath {
|
||||
case .getVirtualCurrencies:
|
||||
return "get_virtual_currencies"
|
||||
|
||||
case .spendVirtualCurrencies:
|
||||
return "spend_virtual_currencies"
|
||||
|
||||
case .appHealthReportAvailability:
|
||||
return "get_app_health_report_availability"
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// SpendVirtualCurrenciesOperation.swift
|
||||
//
|
||||
// Created by Dave DeLong on 8/20/26.
|
||||
|
||||
import Foundation
|
||||
|
||||
final class SpendVirtualCurrenciesOperation: NetworkOperation {
|
||||
|
||||
private let amounts: [String: Int]
|
||||
private let reference: String?
|
||||
private let handler: VirtualCurrenciesAPI.VirtualCurrenciesResponseHandler
|
||||
private let idempotencyKey: String
|
||||
|
||||
init(configuration: NetworkConfiguration,
|
||||
amounts: [String: Int],
|
||||
reference: String?,
|
||||
handler: @escaping VirtualCurrenciesAPI.VirtualCurrenciesResponseHandler) {
|
||||
self.amounts = amounts
|
||||
self.reference = reference
|
||||
self.handler = handler
|
||||
self.idempotencyKey = UUID().uuidString
|
||||
|
||||
super.init(configuration: configuration)
|
||||
}
|
||||
|
||||
override func begin(completion: @escaping () -> Void) {
|
||||
let body = Body(adjustments: amounts, reference: reference)
|
||||
let httpRequest = HTTPRequest(method: .post(body),
|
||||
path: .spendVirtualCurrencies,
|
||||
additionalHeaders: [
|
||||
"Idempotency-Key": self.idempotencyKey
|
||||
])
|
||||
|
||||
self.httpClient.perform(httpRequest) { (response: VerifiedHTTPResponse<VirtualCurrenciesResponse>.Result) in
|
||||
defer {
|
||||
completion()
|
||||
}
|
||||
|
||||
let result = response.map(\.body).mapError(BackendError.networkError)
|
||||
self.handler(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restating inherited @unchecked Sendable from Foundation's Operation
|
||||
extension SpendVirtualCurrenciesOperation: @unchecked Sendable {}
|
||||
|
||||
extension SpendVirtualCurrenciesOperation {
|
||||
|
||||
struct Body: Encodable, HTTPRequestBody {
|
||||
|
||||
let adjustments: [String: Int]
|
||||
let reference: String?
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,4 +49,19 @@ class VirtualCurrenciesAPI {
|
||||
cacheStatus: cacheStatus
|
||||
)
|
||||
}
|
||||
|
||||
func spendVirtualCurrencies(amounts: [String: Int],
|
||||
reference: String?,
|
||||
completion: @escaping VirtualCurrenciesResponseHandler) {
|
||||
let config = NetworkOperation.Configuration(
|
||||
httpClient: self.backendConfig.httpClient
|
||||
)
|
||||
|
||||
let operation = SpendVirtualCurrenciesOperation(configuration: config,
|
||||
amounts: amounts,
|
||||
reference: reference,
|
||||
handler: completion)
|
||||
|
||||
self.backendConfig.operationQueue.addOperation(operation)
|
||||
}
|
||||
}
|
||||
@@ -1720,6 +1720,68 @@ public extension Purchases {
|
||||
@objc func invalidateVirtualCurrenciesCache() {
|
||||
self.virtualCurrencyManager.invalidateVirtualCurrenciesCache()
|
||||
}
|
||||
|
||||
/// Spend virtual currency
|
||||
///
|
||||
/// Spending virtual currency is only allowed when the SDK is configured using
|
||||
/// ``Configuration.Builder.with(iamEnabled:)``. Attempting to spend virtual currency
|
||||
/// without enabling IAM will result in a thrown error.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - amounts: A dictionary containing the amounts of each currency to spend.
|
||||
/// The key is virtual currency's `code`, and the value is how much of that currency to spend.
|
||||
/// All values should be greater than zero. Values less than or equal to zero are ignored.
|
||||
///
|
||||
/// If the dictionary is empty or all values are less thn or equal to zero, then no currencies
|
||||
/// will be spent, and this will return the result of invoking ``virtualCurrencies()``.
|
||||
///
|
||||
/// You may specify multiple currencies to spend in a single transaction.
|
||||
/// - reference: An optional app-specific reference string that refers to this transaction.
|
||||
/// - Returns: The latest ``VirtualCurrencies`` for the user after the transaction has processed
|
||||
@_spi(Internal)
|
||||
func spendVirtualCurrencies(amounts: [String: Int], reference: String? = nil) async throws -> VirtualCurrencies {
|
||||
guard self.tokenManager.enabled else {
|
||||
let message = "Spending virtual currencies requires configuring the SDK .with(iamEnabled: true)"
|
||||
let error = NewErrorUtils.unsupportedError(message: message)
|
||||
throw error
|
||||
}
|
||||
|
||||
do {
|
||||
return try await self.virtualCurrencyManager.spendVirtualCurrencies(amounts: amounts,
|
||||
reference: reference)
|
||||
} catch {
|
||||
let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError
|
||||
throw publicError
|
||||
}
|
||||
}
|
||||
|
||||
/// Spend virtual currency
|
||||
///
|
||||
/// - SeeAlso: ``spendVirtualCurrencies(amounts:reference:)``
|
||||
@_spi(Internal)
|
||||
func spendVirtualCurrency(code: String, amount: Int, reference: String? = nil) async throws -> VirtualCurrencies {
|
||||
return try await spendVirtualCurrencies(amounts: [code: amount], reference: reference)
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
@objc
|
||||
func spendVirtualCurrencies(amounts: [String: Int],
|
||||
reference: String?,
|
||||
completion: @escaping (VirtualCurrencies?, PublicError?) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let virtualCurrencies = try await self.spendVirtualCurrencies(amounts: amounts, reference: reference)
|
||||
OperationDispatcher.dispatchOnMainActor {
|
||||
completion(virtualCurrencies, nil)
|
||||
}
|
||||
} catch {
|
||||
let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError
|
||||
OperationDispatcher.dispatchOnMainActor {
|
||||
completion(nil, publicError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -1009,6 +1009,22 @@ public protocol PurchasesType: AnyObject {
|
||||
completion: @escaping @Sendable (VirtualCurrencies?, PublicError?) -> Void
|
||||
)
|
||||
|
||||
/**
|
||||
* Spends virtual currencies
|
||||
*
|
||||
* - Parameter amounts: A dictionary key by ``VirtualCurrency`` codes with values corresponding
|
||||
* to the amount of that currency to spend. Values must be positive non-zero numbers. Negative values will
|
||||
* be interpreted as positive. Zero values are ignored.
|
||||
* - Parameter reference: An app-specified string to refer to this transaction
|
||||
* - Parameter completion: The callback that is invoked with the request is complete
|
||||
* - Warning: Using this method requires enabling IAM.
|
||||
*/
|
||||
@objc
|
||||
@_spi(Internal)
|
||||
func spendVirtualCurrencies(amounts: [String: Int],
|
||||
reference: String?,
|
||||
completion: @escaping (VirtualCurrencies?, PublicError?) -> Void)
|
||||
|
||||
/**
|
||||
* The currently cached ``VirtualCurrencies`` if one is available.
|
||||
* This is synchronous, and therefore useful for contexts where an app needs a `VirtualCurrencies`
|
||||
|
||||
@@ -15,6 +15,7 @@ import Foundation
|
||||
|
||||
protocol VirtualCurrencyManagerType {
|
||||
func virtualCurrencies() async throws -> VirtualCurrencies
|
||||
func spendVirtualCurrencies(amounts: [String: Int], reference: String?) async throws -> VirtualCurrencies
|
||||
|
||||
func cachedVirtualCurrencies() -> VirtualCurrencies?
|
||||
|
||||
@@ -62,6 +63,42 @@ class VirtualCurrencyManager: VirtualCurrencyManagerType {
|
||||
return virtualCurrencies
|
||||
}
|
||||
|
||||
func spendVirtualCurrencies(amounts: [String: Int], reference: String?) async throws -> VirtualCurrencies {
|
||||
let resolvedAmounts = amounts.compactMap { (code, amount) -> (String, Int)? in
|
||||
if code.count > 100 {
|
||||
// the server limits currency codes to 10 characters or fewer
|
||||
// if this changes in the future, this is still very permissive
|
||||
Logger.warn(Strings.virtualCurrencies.invalid_code)
|
||||
return nil
|
||||
}
|
||||
if amount <= 0 {
|
||||
Logger.warn(Strings.virtualCurrencies.invalid_spend_amount(code, amount))
|
||||
return nil
|
||||
}
|
||||
return (code, amount)
|
||||
}
|
||||
|
||||
guard resolvedAmounts.count < 1000 else {
|
||||
// the server limits projects to 100 specified codes
|
||||
// if this changes in the future, this is still very permissive
|
||||
Logger.warn(Strings.virtualCurrencies.too_many_codes)
|
||||
return try await virtualCurrencies()
|
||||
}
|
||||
|
||||
guard resolvedAmounts.count > 0 else {
|
||||
Logger.warn(Strings.virtualCurrencies.empty_spend_amount)
|
||||
return try await virtualCurrencies()
|
||||
}
|
||||
|
||||
let appUserID = identityManager.currentAppUserID
|
||||
let resolved = Dictionary(uniqueKeysWithValues: resolvedAmounts)
|
||||
let virtualCurrencies = try await spendVirtualCurrenciesOnBackend(amounts: resolved, reference: reference)
|
||||
|
||||
cacheVirtualCurrencies(virtualCurrencies, appUserID: appUserID)
|
||||
|
||||
return virtualCurrencies
|
||||
}
|
||||
|
||||
func cachedVirtualCurrencies() -> VirtualCurrencies? {
|
||||
let appUserID = identityManager.currentAppUserID
|
||||
if let cachedVirtualCurrencies = fetchCachedVirtualCurrencies(
|
||||
@@ -152,4 +189,24 @@ class VirtualCurrencyManager: VirtualCurrencyManagerType {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func spendVirtualCurrenciesOnBackend(amounts: [String: Int],
|
||||
reference: String?) async throws -> VirtualCurrencies {
|
||||
do {
|
||||
|
||||
let response = try await Async.call { completion in
|
||||
backend.virtualCurrenciesAPI.spendVirtualCurrencies(amounts: amounts,
|
||||
reference: reference,
|
||||
completion: { result in
|
||||
completion(result.mapError(\.asPurchasesError))
|
||||
})
|
||||
}
|
||||
|
||||
Logger.debug(Strings.virtualCurrencies.virtual_currencies_spent_on_network)
|
||||
return VirtualCurrencies(from: response)
|
||||
} catch {
|
||||
Logger.error(Strings.virtualCurrencies.virtual_currencies_spent_on_network_error(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,12 @@ NSURL *url;
|
||||
|
||||
RCVirtualCurrencies * _Nullable __unused virtualCurrencies = p.cachedVirtualCurrencies;
|
||||
|
||||
[p spendVirtualCurrenciesWithAmounts:@{ @"GLD": @42 }
|
||||
reference:@"test-123"
|
||||
completion:^(RCVirtualCurrencies * _Nullable vc, NSError * _Nullable err) {
|
||||
|
||||
}];
|
||||
|
||||
if (@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)) {
|
||||
[p recordPurchaseForProductID:@"product_id" completion:^(RCStoreTransaction * _Nullable transaction, NSError * _Nullable error) { }];
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// Created by Madeline Beyl on 8/25/21.
|
||||
|
||||
import Foundation
|
||||
@_spi(Experimental) import RevenueCat
|
||||
@_spi(Internal) @_spi(Experimental) import RevenueCat
|
||||
import StoreKit
|
||||
|
||||
func checkPurchasesAPI() {
|
||||
@@ -519,6 +519,14 @@ private func checkVirtualCurrenciesAPI(_ purchases: Purchases) async throws {
|
||||
|
||||
// Cached virtual currencies
|
||||
let _: VirtualCurrencies? = purchases.cachedVirtualCurrencies
|
||||
|
||||
let _: VirtualCurrencies = try await purchases.spendVirtualCurrencies(amounts: [
|
||||
"GLD": 42
|
||||
], reference: String?.none)
|
||||
|
||||
let _: VirtualCurrencies = try await purchases.spendVirtualCurrency(code: "GLD",
|
||||
amount: 42,
|
||||
reference: "test-123")
|
||||
}
|
||||
|
||||
@available(*, deprecated) // Ignore deprecation warnings
|
||||
|
||||
@@ -490,6 +490,12 @@ extension MockPurchases: PurchasesType {
|
||||
self.unimplemented()
|
||||
}
|
||||
|
||||
func spendVirtualCurrencies(amounts: [String: Int],
|
||||
reference: String?,
|
||||
completion: @escaping (VirtualCurrencies?, PublicError?) -> Void) {
|
||||
self.unimplemented()
|
||||
}
|
||||
|
||||
func invalidateVirtualCurrenciesCache() {
|
||||
self.unimplemented()
|
||||
}
|
||||
|
||||
@@ -37,4 +37,22 @@ class MockVirtualCurrenciesAPI: VirtualCurrenciesAPI {
|
||||
|
||||
completion(stubbedGetVirtualCurrenciesResult ?? .failure(.missingAppUserID()))
|
||||
}
|
||||
|
||||
var invokedSpendVirtualCurrencies = false
|
||||
var invokedSpendVirtualCurrenciesCount = 0
|
||||
var invokedSpendVirtualCurrenciesParameters: (amounts: [String: Int], reference: String?)?
|
||||
|
||||
var stubbedSpendVirtualCurrenciesResult: Result<VirtualCurrenciesResponse, BackendError>?
|
||||
|
||||
override func spendVirtualCurrencies(
|
||||
amounts: [String: Int],
|
||||
reference: String?,
|
||||
completion: @escaping VirtualCurrenciesResponseHandler
|
||||
) {
|
||||
invokedSpendVirtualCurrencies = true
|
||||
invokedSpendVirtualCurrenciesCount += 1
|
||||
invokedSpendVirtualCurrenciesParameters = (amounts, reference)
|
||||
|
||||
completion(stubbedSpendVirtualCurrenciesResult ?? .failure(.missingAppUserID()))
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,21 @@ class MockVirtualCurrencyManager: VirtualCurrencyManagerType {
|
||||
return try stubbedVirtualCurrenciesResult.get()
|
||||
}
|
||||
|
||||
var stubbedSpendVirtualCurrenciesResult: Result<RevenueCat.VirtualCurrencies, Error> = .success(VirtualCurrencies(
|
||||
virtualCurrencies: [:]
|
||||
))
|
||||
|
||||
var spendVirtualCurrenciesCallCount = 0
|
||||
var spendVirtualCurrenciesCalled = false
|
||||
var invokedSpendVirtualCurrenciesParametersList: [(amounts: [String: Int], reference: String?)] = []
|
||||
func spendVirtualCurrencies(amounts: [String: Int], reference: String?) async throws -> VirtualCurrencies {
|
||||
self.spendVirtualCurrenciesCallCount += 1
|
||||
self.spendVirtualCurrenciesCalled = true
|
||||
self.invokedSpendVirtualCurrenciesParametersList.append((amounts, reference))
|
||||
|
||||
return try stubbedSpendVirtualCurrenciesResult.get()
|
||||
}
|
||||
|
||||
var invalidateVirtualCurrenciesCacheCallCount = 0
|
||||
var invalidateVirtualCurrenciesCacheCalled = false
|
||||
func invalidateVirtualCurrenciesCache() {
|
||||
|
||||
@@ -46,4 +46,46 @@ class BackendErrorCodeTests: TestCase {
|
||||
expect(BackendErrorCode.cannotAliasToAuthenticatedUser.toPurchasesErrorCode()) == .configurationError
|
||||
}
|
||||
|
||||
// MARK: - unknownVirtualCurrencyCode
|
||||
|
||||
func testUnknownVirtualCurrencyCodeDecodesFromItsRawIntValue() {
|
||||
expect(BackendErrorCode(code: 7870)) == .unknownVirtualCurrencyCode
|
||||
}
|
||||
|
||||
func testUnknownVirtualCurrencyCodeDecodesFromItsRawStringValue() {
|
||||
expect(BackendErrorCode(code: "7870")) == .unknownVirtualCurrencyCode
|
||||
}
|
||||
|
||||
func testUnknownVirtualCurrencyCodeMapsToPurchaseInvalidError() {
|
||||
expect(BackendErrorCode.unknownVirtualCurrencyCode.toPurchasesErrorCode()) == .purchaseInvalidError
|
||||
}
|
||||
|
||||
// MARK: - duplicateVirtualCurrencyTransaction
|
||||
|
||||
func testDuplicateVirtualCurrencyTransactionDecodesFromItsRawIntValue() {
|
||||
expect(BackendErrorCode(code: 8139)) == .duplicateVirtualCurrencyTransaction
|
||||
}
|
||||
|
||||
func testDuplicateVirtualCurrencyTransactionDecodesFromItsRawStringValue() {
|
||||
expect(BackendErrorCode(code: "8139")) == .duplicateVirtualCurrencyTransaction
|
||||
}
|
||||
|
||||
func testDuplicateVirtualCurrencyTransactionMapsToPurchaseInvalidError() {
|
||||
expect(BackendErrorCode.duplicateVirtualCurrencyTransaction.toPurchasesErrorCode()) == .purchaseInvalidError
|
||||
}
|
||||
|
||||
// MARK: - invalidIdempotencyKey
|
||||
|
||||
func testInvalidIdempotencyKeyDecodesFromItsRawIntValue() {
|
||||
expect(BackendErrorCode(code: 8140)) == .invalidIdempotencyKey
|
||||
}
|
||||
|
||||
func testInvalidIdempotencyKeyDecodesFromItsRawStringValue() {
|
||||
expect(BackendErrorCode(code: "8140")) == .invalidIdempotencyKey
|
||||
}
|
||||
|
||||
func testInvalidIdempotencyKeyMapsToPurchaseInvalidError() {
|
||||
expect(BackendErrorCode.invalidIdempotencyKey.toPurchasesErrorCode()) == .purchaseInvalidError
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// SpendVirtualCurrenciesOperationBodyTests.swift
|
||||
//
|
||||
// Created by RevenueCat on 8/25/26.
|
||||
|
||||
import Nimble
|
||||
import XCTest
|
||||
|
||||
@testable import RevenueCat
|
||||
|
||||
// NOTE: These tests intentionally exercise `SpendVirtualCurrenciesOperation.Body`'s `Encodable`
|
||||
// conformance directly rather than driving the operation through `MockHTTPClient`. As written,
|
||||
// `HTTPRequestPath.pathComponent`'s `.spendVirtualCurrencies` case calls `assertionFailure(...)`,
|
||||
// and `MockHTTPClient.perform` always resolves the mock lookup URL with `preferIAMPath: false`
|
||||
// (see `HTTPRequestPath.url(preferIAMPath:)`), which means `pathComponent` (not `iamPathComponent`)
|
||||
// is always evaluated. Any test that performs an actual `.spendVirtualCurrencies` HTTP request
|
||||
// through `MockHTTPClient` would therefore always hit that `assertionFailure`, crashing the test
|
||||
// process. Until the test double is updated to honor `tokenManager.enabled` (mirroring what
|
||||
// `HTTPClient.perform` does in production), the network round-trip for this operation can't be
|
||||
// safely exercised with the existing test infrastructure.
|
||||
class SpendVirtualCurrenciesOperationBodyTests: TestCase {
|
||||
|
||||
func testBodyEncodesAdjustmentsAndReference() throws {
|
||||
let body = SpendVirtualCurrenciesOperation.Body(
|
||||
adjustments: ["GLD": 50, "SLV": 10],
|
||||
reference: "order-123"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(body)
|
||||
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
let adjustments = try XCTUnwrap(json["adjustments"] as? [String: Int])
|
||||
expect(adjustments) == ["GLD": 50, "SLV": 10]
|
||||
expect(json["reference"] as? String) == "order-123"
|
||||
}
|
||||
|
||||
func testBodyOmitsReferenceKeyWhenNil() throws {
|
||||
let body = SpendVirtualCurrenciesOperation.Body(
|
||||
adjustments: ["GLD": 50],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(body)
|
||||
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
expect(json["adjustments"] as? [String: Int]) == ["GLD": 50]
|
||||
expect(json.keys.contains("reference")).to(beFalse())
|
||||
}
|
||||
|
||||
func testBodyEncodesEmptyAdjustments() throws {
|
||||
let body = SpendVirtualCurrenciesOperation.Body(adjustments: [:], reference: nil)
|
||||
|
||||
let data = try JSONEncoder().encode(body)
|
||||
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
expect(json["adjustments"] as? [String: Int]) == [:]
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,14 +27,9 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
]
|
||||
)
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
self.setupPurchases()
|
||||
}
|
||||
|
||||
// MARK: - virtualCurrencies() Tests
|
||||
func testVirtualCurrenciesAsyncForwardsSuccess() async throws {
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
let vcs = try await self.purchases.virtualCurrencies()
|
||||
@@ -44,6 +39,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testVirtualCurrenciesCallbackForwardsSuccess() async throws {
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
await waitUntil { completed in
|
||||
@@ -59,6 +55,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testVirtualCurrenciesAsyncForwardsError() async throws {
|
||||
self.setupPurchases()
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
self.mockVirtualCurrencyManager.stubbedVirtualCurrenciesResult = .failure(backendError)
|
||||
|
||||
@@ -73,6 +70,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testVirtualCurrenciesCallbackForwardsError() async throws {
|
||||
self.setupPurchases()
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
self.mockVirtualCurrencyManager.stubbedVirtualCurrenciesResult = .failure(backendError)
|
||||
|
||||
@@ -89,6 +87,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testVirtualCurrenciesCallbackCallsSuccessOnMainThread() async throws {
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
await waitUntil { completed in
|
||||
@@ -100,6 +99,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testVirtualCurrenciesCallbackCallsErrorOnMainThread() async throws {
|
||||
self.setupPurchases()
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
self.mockVirtualCurrencyManager.stubbedVirtualCurrenciesResult = .failure(backendError)
|
||||
|
||||
@@ -113,6 +113,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
|
||||
// MARK: - invalidateVirtualCurrenciesCache() Tests
|
||||
func testInvalidateVirtualCurrenciesCacheCallsVirtualCurrencyManagerInvalidateVirtualCurrenciesCache() async {
|
||||
self.setupPurchases()
|
||||
self.purchases.invalidateVirtualCurrenciesCache()
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCalled).to(beTrue())
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount).to(equal(1))
|
||||
@@ -121,6 +122,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
|
||||
// MARK: - cachedVirtualCurrencies Tests
|
||||
func testCachedVirtualCurrenciesReturnsCachedVirtualCurrencies() {
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedCachedVirtualCurrencies = Self.mockVirtualCurrencies
|
||||
|
||||
let cachedVirtualCurrencies = self.purchases.cachedVirtualCurrencies
|
||||
@@ -131,6 +133,7 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testCachedVirtualCurrenciesReturnsNilWhenThereAreNoCachedVirtualCurrencies() {
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedCachedVirtualCurrencies = nil
|
||||
|
||||
let cachedVirtualCurrencies = self.purchases.cachedVirtualCurrencies
|
||||
@@ -139,4 +142,166 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
|
||||
expect(self.mockVirtualCurrencyManager.cachedVirtualCurrenciesCallCount).to(equal(1))
|
||||
expect(Thread.isMainThread).to(beTrue())
|
||||
}
|
||||
|
||||
// MARK: - spendVirtualCurrencies() Tests
|
||||
|
||||
func testSpendVirtualCurrenciesThrowsUnsupportedErrorWhenIAMIsNotEnabled() async throws {
|
||||
self.setupPurchases()
|
||||
// `self.tokenManager` defaults to disabled in `BasePurchasesTests.setUpWithError()`.
|
||||
var thrown: Error?
|
||||
do {
|
||||
_ = try await self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil)
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).to(matchError(ErrorCode.unsupportedError))
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beFalse())
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyThrowsUnsupportedErrorWhenIAMIsNotEnabled() async throws {
|
||||
self.setupPurchases()
|
||||
var thrown: Error?
|
||||
do {
|
||||
_ = try await self.purchases.spendVirtualCurrency(code: "GLD", amount: 10, reference: nil)
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).to(matchError(ErrorCode.unsupportedError))
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beFalse())
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesForwardsSuccessWhenIAMIsEnabled() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
let vcs = try await self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: "ref-1")
|
||||
|
||||
expect(vcs).to(equal(Self.mockVirtualCurrencies))
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beTrue())
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCallCount).to(equal(1))
|
||||
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.amounts)
|
||||
== ["GLD": 10]
|
||||
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.reference)
|
||||
== "ref-1"
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesForwardsErrorWhenIAMIsEnabled() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .failure(backendError)
|
||||
|
||||
do {
|
||||
_ = try await self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil)
|
||||
fail("An error should have been thrown")
|
||||
} catch {
|
||||
expect(error).to(matchError(backendError.asPurchasesError))
|
||||
}
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyConvenienceMethodForwardsSingleAmountWhenIAMIsEnabled() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
let vcs = try await self.purchases.spendVirtualCurrency(code: "GLD", amount: 25, reference: "ref-2")
|
||||
|
||||
expect(vcs).to(equal(Self.mockVirtualCurrencies))
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCallCount).to(equal(1))
|
||||
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.amounts)
|
||||
== ["GLD": 25]
|
||||
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.reference)
|
||||
== "ref-2"
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyConvenienceMethodForwardsErrorWhenIAMIsEnabled() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .failure(backendError)
|
||||
|
||||
do {
|
||||
_ = try await self.purchases.spendVirtualCurrency(code: "GLD", amount: 25, reference: nil)
|
||||
fail("An error should have been thrown")
|
||||
} catch {
|
||||
expect(error).to(matchError(backendError.asPurchasesError))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - spendVirtualCurrency(amounts:reference:completion:) Tests
|
||||
|
||||
func testSpendVirtualCurrencyCallbackThrowsUnsupportedErrorWhenIAMIsNotEnabled() async throws {
|
||||
self.setupPurchases()
|
||||
await waitUntil { completed in
|
||||
self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil) { vcs, error in
|
||||
expect(vcs).to(beNil())
|
||||
expect(error).to(matchError(ErrorCode.unsupportedError))
|
||||
completed()
|
||||
}
|
||||
}
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beFalse())
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyCallbackForwardsSuccessWhenIAMIsEnabled() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
await waitUntil { completed in
|
||||
self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: "ref-3") { vcs, error in
|
||||
expect(vcs).to(equal(Self.mockVirtualCurrencies))
|
||||
expect(error).to(beNil())
|
||||
completed()
|
||||
}
|
||||
}
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCallCount).to(equal(1))
|
||||
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.amounts)
|
||||
== ["GLD": 10]
|
||||
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.reference)
|
||||
== "ref-3"
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyCallbackForwardsErrorWhenIAMIsEnabled() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .failure(backendError)
|
||||
|
||||
await waitUntil { completed in
|
||||
self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil) { vcs, error in
|
||||
expect(vcs).to(beNil())
|
||||
expect(error).to(matchError(backendError.asPurchasesError))
|
||||
completed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyCallbackCallsSuccessOnMainThread() async throws {
|
||||
self.tokenManager = MockTokenManager(enabled: true)
|
||||
self.setupPurchases()
|
||||
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
|
||||
|
||||
await waitUntil { completed in
|
||||
self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil) { _, _ in
|
||||
expect(Thread.isMainThread).to(beTrue())
|
||||
completed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrencyCallbackCallsErrorOnMainThread() async throws {
|
||||
self.setupPurchases()
|
||||
// `self.tokenManager` defaults to disabled, so this exercises the error path via the guard.
|
||||
await waitUntil { completed in
|
||||
self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil) { _, _ in
|
||||
expect(Thread.isMainThread).to(beTrue())
|
||||
completed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,4 +502,313 @@ class VirtualCurrencyManagerTests: TestCase {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - spendVirtualCurrencies() Tests
|
||||
|
||||
func testSpendVirtualCurrenciesSendsResolvedAmountsToBackendAndReturnsResult() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
let virtualCurrencies = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": 50],
|
||||
reference: "order-123"
|
||||
)
|
||||
|
||||
XCTAssertTrue(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrencies)
|
||||
XCTAssertEqual(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesCount, 1)
|
||||
XCTAssertEqual(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts, ["GLD": 50])
|
||||
XCTAssertEqual(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.reference, "order-123")
|
||||
|
||||
let expectedVirtualCurrencies = VirtualCurrencies(from: self.mockVirtualCurrenciesResponse)
|
||||
XCTAssertEqual(virtualCurrencies, expectedVirtualCurrencies)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesFiltersOutZeroAmountsAndFetchesInsteadOfSpending() async throws {
|
||||
self.mockDeviceCache.stubbedCachedVirtualCurrenciesDataForAppUserID = self.mockVirtualCurrenciesData
|
||||
self.mockDeviceCache.stubbedIsVirtualCurrenciesCacheStale = false
|
||||
|
||||
let virtualCurrencies = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": 0],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrencies,
|
||||
"spendVirtualCurrencies should not call the backend when all amounts resolve to zero"
|
||||
)
|
||||
XCTAssertEqual(virtualCurrencies, self.mockVirtualCurrencies)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_spend_amount("GLD", 0),
|
||||
level: .warn
|
||||
)
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.empty_spend_amount,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesWithEmptyAmountsDictionaryFallsBackToFetchingVirtualCurrencies() async throws {
|
||||
self.mockDeviceCache.stubbedCachedVirtualCurrenciesDataForAppUserID = self.mockVirtualCurrenciesData
|
||||
self.mockDeviceCache.stubbedIsVirtualCurrenciesCacheStale = false
|
||||
|
||||
let virtualCurrencies = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: [:],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertFalse(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrencies)
|
||||
XCTAssertEqual(virtualCurrencies, self.mockVirtualCurrencies)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.empty_spend_amount,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesFiltersOutNegativeAmountsAndLogsWarning() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": -50, "SLV": 20],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
// The negative amount should be dropped entirely rather than being sent as a positive amount.
|
||||
XCTAssertEqual(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts, ["SLV": 20])
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_spend_amount("GLD", -50),
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesWithOnlyNegativeAmountFallsBackToFetchingVirtualCurrencies() async throws {
|
||||
self.mockDeviceCache.stubbedCachedVirtualCurrenciesDataForAppUserID = self.mockVirtualCurrenciesData
|
||||
self.mockDeviceCache.stubbedIsVirtualCurrenciesCacheStale = false
|
||||
|
||||
let virtualCurrencies = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": -50],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertFalse(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrencies)
|
||||
XCTAssertEqual(virtualCurrencies, self.mockVirtualCurrencies)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_spend_amount("GLD", -50),
|
||||
level: .warn
|
||||
)
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.empty_spend_amount,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesFiltersOutZeroAndNegativeAmountsKeepingOnlyPositiveAmounts() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": 100, "SLV": 0, "PLT": -30],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts,
|
||||
["GLD": 100]
|
||||
)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_spend_amount("SLV", 0),
|
||||
level: .warn
|
||||
)
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_spend_amount("PLT", -30),
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesFiltersOutCodesLongerThanTheLengthLimitAndLogsWarning() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
let tooLongCode = String(repeating: "A", count: 101)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: [tooLongCode: 10, "GLD": 25],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts,
|
||||
["GLD": 25]
|
||||
)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_code,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesAllowsCodesExactlyAtTheLengthLimit() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
let maxLengthCode = String(repeating: "A", count: 100)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: [maxLengthCode: 10],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts,
|
||||
[maxLengthCode: 10]
|
||||
)
|
||||
|
||||
self.logger.verifyMessageWasNotLogged(
|
||||
Strings.virtualCurrencies.invalid_code,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesFiltersOutInvalidCodesAndInvalidAmountsIndependently() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
let tooLongCode = String(repeating: "A", count: 101)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: [tooLongCode: 10, "GLD": -5, "SLV": 100],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts,
|
||||
["SLV": 100]
|
||||
)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_code,
|
||||
level: .warn
|
||||
)
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.invalid_spend_amount("GLD", -5),
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesFallsBackToFetchingWhenTooManyCodesAreResolved() async throws {
|
||||
self.mockDeviceCache.stubbedCachedVirtualCurrenciesDataForAppUserID = self.mockVirtualCurrenciesData
|
||||
self.mockDeviceCache.stubbedIsVirtualCurrenciesCacheStale = false
|
||||
|
||||
let manyAmounts = Dictionary(uniqueKeysWithValues: (0..<1000).map { ("CODE\($0)", 1) })
|
||||
|
||||
let virtualCurrencies = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: manyAmounts,
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrencies,
|
||||
"spendVirtualCurrencies should not call the backend when too many codes are resolved"
|
||||
)
|
||||
XCTAssertEqual(virtualCurrencies, self.mockVirtualCurrencies)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.too_many_codes,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesSpendsWhenResolvedCountIsJustUnderTheCodeLimit() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
let manyAmounts = Dictionary(uniqueKeysWithValues: (0..<999).map { ("CODE\($0)", 1) })
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: manyAmounts,
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrencies)
|
||||
XCTAssertEqual(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.amounts.count,
|
||||
999
|
||||
)
|
||||
|
||||
self.logger.verifyMessageWasNotLogged(
|
||||
Strings.virtualCurrencies.too_many_codes,
|
||||
level: .warn
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesPassesReferenceThrough() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": 10],
|
||||
reference: "reference-456"
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.reference,
|
||||
"reference-456"
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesPassesNilReferenceThrough() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(
|
||||
amounts: ["GLD": 10],
|
||||
reference: nil
|
||||
)
|
||||
|
||||
XCTAssertNil(self.mockVirtualCurrenciesAPI.invokedSpendVirtualCurrenciesParameters?.reference)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesCachesResultOnSuccess() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .success(self.mockVirtualCurrenciesResponse)
|
||||
|
||||
let expectedVirtualCurrenciesToBeCached = VirtualCurrencies(from: self.mockVirtualCurrenciesResponse)
|
||||
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil)
|
||||
|
||||
XCTAssertTrue(self.mockDeviceCache.invokedCacheVirtualCurrencies)
|
||||
XCTAssertEqual(self.mockDeviceCache.invokedCacheVirtualCurrenciesCount, 1)
|
||||
|
||||
let cachedVirtualCurrenciesData = self.mockDeviceCache.invokedCacheVirtualCurrenciesParametersList[0].0
|
||||
let cachedVirtualCurrencies = try JSONDecoder().decode(
|
||||
VirtualCurrencies.self,
|
||||
from: cachedVirtualCurrenciesData
|
||||
)
|
||||
XCTAssertEqual(cachedVirtualCurrencies, expectedVirtualCurrenciesToBeCached)
|
||||
XCTAssertEqual(
|
||||
self.mockDeviceCache.invokedCacheVirtualCurrenciesParametersList[0].1, self.appUserID
|
||||
)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.virtual_currencies_spent_on_network,
|
||||
level: .debug
|
||||
)
|
||||
}
|
||||
|
||||
func testSpendVirtualCurrenciesPropagatesNetworkErrorsAndDoesNotCache() async throws {
|
||||
self.mockVirtualCurrenciesAPI.stubbedSpendVirtualCurrenciesResult = .failure(
|
||||
.networkError(NetworkError.serverDown())
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await virtualCurrencyManager.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil)
|
||||
|
||||
XCTFail("An error should have been thrown when the network call failed.")
|
||||
} catch {
|
||||
let purchasesError = try XCTUnwrap(error as? PurchasesError)
|
||||
XCTAssertEqual(purchasesError.error, ErrorCode.unknownBackendError)
|
||||
|
||||
XCTAssertFalse(self.mockDeviceCache.invokedCacheVirtualCurrencies)
|
||||
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.virtualCurrencies.virtual_currencies_spent_on_network_error(error),
|
||||
level: .error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user