(Internal) Spend virtual currencies (#7507)

This commit is contained in:
Dave DeLong authored and GitHub committed 2026-08-27 16:14:19 -06:00
1 parent 2dbb74e59a
commit a1781cb28e
18 files changed
+910 -9

No files matched your search

+9 -1
View File
@@ -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
)
}
}
}