Skip to content

Commit d1bcd7e

Browse files
committed
tests and documentation
1 parent dbbd80a commit d1bcd7e

9 files changed

Lines changed: 456 additions & 4 deletions

File tree

Sources/Purchasing/Purchases/Purchases.swift

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,9 +1720,27 @@ public extension Purchases {
17201720
@objc func invalidateVirtualCurrenciesCache() {
17211721
self.virtualCurrencyManager.invalidateVirtualCurrenciesCache()
17221722
}
1723-
1723+
1724+
/// Spend virtual currency
1725+
///
1726+
/// Spending virtual currency is only allowed when the SDK is configured using ``Configuration.Builder.with(iamEnabled:)``.
1727+
/// Attempting to spend virtual currency without enabling IAM will result in a thrown error.
1728+
///
1729+
/// - Parameters:
1730+
/// - amounts: A dictionary containing the amounts of each currency to spend. The key is virtual currency's `code`,
1731+
/// and the value is how much of that currency to spend. All values should be greater than zero. Values equal to zero are ignored,
1732+
/// and values less than zero will be interpreted as positive.
1733+
///
1734+
/// In other words, `["VC_CODE": -42]` and `["VC_CODE": 42]` will be interpreted as equivalent.
1735+
///
1736+
/// If the dictionary is empty or all values are zero, then no currencies will be spent,
1737+
/// and this will return the result of invoking ``virtualCurrencies()``.
1738+
///
1739+
/// You may specify multiple currencies to spend in a single transaction.
1740+
/// - reference: An optional app-specific reference string that refers to this transaction.
1741+
/// - Returns: The latest ``VirtualCurrencies`` for the user after the transaction has processed
17241742
@_spi(Internal)
1725-
func spendVirtualCurrencies(amounts: [String: Int], reference: String?) async throws -> VirtualCurrencies {
1743+
func spendVirtualCurrencies(amounts: [String: Int], reference: String? = nil) async throws -> VirtualCurrencies {
17261744
guard self.tokenManager.enabled else {
17271745
let message = "Spending virtual currencies requires .with(iamEnabled: true)"
17281746
let error = NewErrorUtils.unsupportedError(message: message)
@@ -1737,11 +1755,34 @@ public extension Purchases {
17371755
throw publicError
17381756
}
17391757
}
1740-
1758+
1759+
/// Spend virtual currency
1760+
///
1761+
/// - SeeAlso: ``spendVirtualCurrencies(amounts:reference:)``
17411762
@_spi(Internal)
1742-
func spendVirtualCurrency(code: String, amount: Int, reference: String?) async throws -> VirtualCurrencies {
1763+
func spendVirtualCurrency(code: String, amount: Int, reference: String? = nil) async throws -> VirtualCurrencies {
17431764
return try await spendVirtualCurrencies(amounts: [code: amount], reference: reference)
17441765
}
1766+
1767+
@_spi(Internal)
1768+
@objc
1769+
func spendVirtualCurrency(amounts: [String: Int],
1770+
reference: String?,
1771+
completion: @escaping (VirtualCurrencies?, PublicError?) -> Void) {
1772+
Task {
1773+
do {
1774+
let virtualCurrencies = try await self.spendVirtualCurrencies(amounts: amounts, reference: reference)
1775+
OperationDispatcher.dispatchOnMainActor {
1776+
completion(virtualCurrencies, nil)
1777+
}
1778+
} catch {
1779+
let publicError = NewErrorUtils.purchasesError(withUntypedError: error).asPublicError
1780+
OperationDispatcher.dispatchOnMainActor {
1781+
completion(nil, publicError)
1782+
}
1783+
}
1784+
}
1785+
}
17451786
}
17461787
#endif
17471788

Sources/Purchasing/Purchases/PurchasesType.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,22 @@ public protocol PurchasesType: AnyObject {
10091009
completion: @escaping @Sendable (VirtualCurrencies?, PublicError?) -> Void
10101010
)
10111011

1012+
/**
1013+
* Spends virtual currencies
1014+
*
1015+
* - Parameter amounts: A dictionary key by ``VirtualCurrency`` codes with values corresponding
1016+
* to the amount of that currency to spend. Values must be positive non-zero numbers. Negative values will
1017+
* be interpreted as positive. Zero values are ignored.
1018+
* - Parameter reference: An app-specified string to refer to this transaction
1019+
* - Parameter completion: The callback that is invoked with the request is complete
1020+
* - Warning: Using this method requires enabling IAM.
1021+
*/
1022+
@objc
1023+
@_spi(Internal)
1024+
func spendVirtualCurrency(amounts: [String: Int],
1025+
reference: String?,
1026+
completion: @escaping (VirtualCurrencies?, PublicError?) -> Void)
1027+
10121028
/**
10131029
* The currently cached ``VirtualCurrencies`` if one is available.
10141030
* This is synchronous, and therefore useful for contexts where an app needs a `VirtualCurrencies`

Tests/UnitTests/Mocks/MockPurchases.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,12 @@ extension MockPurchases: PurchasesType {
490490
self.unimplemented()
491491
}
492492

493+
func spendVirtualCurrencies(amount: [String: Int],
494+
reference: String?,
495+
completion: @escaping (RevenueCat.VirtualCurrencies?, RevenueCat.PublicError?) -> Void) {
496+
self.unimplemented()
497+
}
498+
493499
func invalidateVirtualCurrenciesCache() {
494500
self.unimplemented()
495501
}

Tests/UnitTests/Mocks/MockVirtualCurrenciesAPI.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,22 @@ class MockVirtualCurrenciesAPI: VirtualCurrenciesAPI {
3737

3838
completion(stubbedGetVirtualCurrenciesResult ?? .failure(.missingAppUserID()))
3939
}
40+
41+
var invokedSpendVirtualCurrencies = false
42+
var invokedSpendVirtualCurrenciesCount = 0
43+
var invokedSpendVirtualCurrenciesParameters: (amounts: [String: Int], reference: String?)?
44+
45+
var stubbedSpendVirtualCurrenciesResult: Result<VirtualCurrenciesResponse, BackendError>?
46+
47+
override func spendVirtualCurrencies(
48+
amounts: [String: Int],
49+
reference: String?,
50+
completion: @escaping VirtualCurrenciesResponseHandler
51+
) {
52+
invokedSpendVirtualCurrencies = true
53+
invokedSpendVirtualCurrenciesCount += 1
54+
invokedSpendVirtualCurrenciesParameters = (amounts, reference)
55+
56+
completion(stubbedSpendVirtualCurrenciesResult ?? .failure(.missingAppUserID()))
57+
}
4058
}

Tests/UnitTests/Mocks/MockVirtualCurrencyManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ class MockVirtualCurrencyManager: VirtualCurrencyManagerType {
2929
return try stubbedVirtualCurrenciesResult.get()
3030
}
3131

32+
var stubbedSpendVirtualCurrenciesResult: Result<RevenueCat.VirtualCurrencies, Error> = .success(VirtualCurrencies(
33+
virtualCurrencies: [:]
34+
))
35+
36+
var spendVirtualCurrenciesCallCount = 0
37+
var spendVirtualCurrenciesCalled = false
38+
var invokedSpendVirtualCurrenciesParametersList: [(amounts: [String: Int], reference: String?)] = []
39+
func spendVirtualCurrencies(amounts: [String: Int], reference: String?) async throws -> RevenueCat.VirtualCurrencies {
40+
self.spendVirtualCurrenciesCallCount += 1
41+
self.spendVirtualCurrenciesCalled = true
42+
self.invokedSpendVirtualCurrenciesParametersList.append((amounts, reference))
43+
44+
return try stubbedSpendVirtualCurrenciesResult.get()
45+
}
46+
3247
var invalidateVirtualCurrenciesCacheCallCount = 0
3348
var invalidateVirtualCurrenciesCacheCalled = false
3449
func invalidateVirtualCurrenciesCache() {

Tests/UnitTests/Networking/BackendErrorCodeTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,46 @@ class BackendErrorCodeTests: TestCase {
4646
expect(BackendErrorCode.cannotAliasToAuthenticatedUser.toPurchasesErrorCode()) == .configurationError
4747
}
4848

49+
// MARK: - unknownVirtualCurrencyCode
50+
51+
func testUnknownVirtualCurrencyCodeDecodesFromItsRawIntValue() {
52+
expect(BackendErrorCode(code: 7870)) == .unknownVirtualCurrencyCode
53+
}
54+
55+
func testUnknownVirtualCurrencyCodeDecodesFromItsRawStringValue() {
56+
expect(BackendErrorCode(code: "7870")) == .unknownVirtualCurrencyCode
57+
}
58+
59+
func testUnknownVirtualCurrencyCodeMapsToPurchaseInvalidError() {
60+
expect(BackendErrorCode.unknownVirtualCurrencyCode.toPurchasesErrorCode()) == .purchaseInvalidError
61+
}
62+
63+
// MARK: - duplicateVirtualCurrencyTransaction
64+
65+
func testDuplicateVirtualCurrencyTransactionDecodesFromItsRawIntValue() {
66+
expect(BackendErrorCode(code: 8139)) == .duplicateVirtualCurrencyTransaction
67+
}
68+
69+
func testDuplicateVirtualCurrencyTransactionDecodesFromItsRawStringValue() {
70+
expect(BackendErrorCode(code: "8139")) == .duplicateVirtualCurrencyTransaction
71+
}
72+
73+
func testDuplicateVirtualCurrencyTransactionMapsToPurchaseInvalidError() {
74+
expect(BackendErrorCode.duplicateVirtualCurrencyTransaction.toPurchasesErrorCode()) == .purchaseInvalidError
75+
}
76+
77+
// MARK: - invalidIdempotencyKey
78+
79+
func testInvalidIdempotencyKeyDecodesFromItsRawIntValue() {
80+
expect(BackendErrorCode(code: 8140)) == .invalidIdempotencyKey
81+
}
82+
83+
func testInvalidIdempotencyKeyDecodesFromItsRawStringValue() {
84+
expect(BackendErrorCode(code: "8140")) == .invalidIdempotencyKey
85+
}
86+
87+
func testInvalidIdempotencyKeyMapsToPurchaseInvalidError() {
88+
expect(BackendErrorCode.invalidIdempotencyKey.toPurchasesErrorCode()) == .purchaseInvalidError
89+
}
90+
4991
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//
2+
// Copyright RevenueCat Inc. All Rights Reserved.
3+
//
4+
// Licensed under the MIT License (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://opensource.org/licenses/MIT
9+
//
10+
// SpendVirtualCurrenciesOperationBodyTests.swift
11+
//
12+
// Created by RevenueCat on 8/25/26.
13+
14+
import Nimble
15+
import XCTest
16+
17+
@testable import RevenueCat
18+
19+
// NOTE: These tests intentionally exercise `SpendVirtualCurrenciesOperation.Body`'s `Encodable`
20+
// conformance directly rather than driving the operation through `MockHTTPClient`. As written,
21+
// `HTTPRequestPath.pathComponent`'s `.spendVirtualCurrencies` case calls `assertionFailure(...)`,
22+
// and `MockHTTPClient.perform` always resolves the mock lookup URL with `preferIAMPath: false`
23+
// (see `HTTPRequestPath.url(preferIAMPath:)`), which means `pathComponent` (not `iamPathComponent`)
24+
// is always evaluated. Any test that performs an actual `.spendVirtualCurrencies` HTTP request
25+
// through `MockHTTPClient` would therefore always hit that `assertionFailure`, crashing the test
26+
// process. Until the test double is updated to honor `tokenManager.enabled` (mirroring what
27+
// `HTTPClient.perform` does in production), the network round-trip for this operation can't be
28+
// safely exercised with the existing test infrastructure.
29+
class SpendVirtualCurrenciesOperationBodyTests: TestCase {
30+
31+
func testBodyEncodesAdjustmentsAndReference() throws {
32+
let body = SpendVirtualCurrenciesOperation.Body(
33+
adjustments: ["GLD": 50, "SLV": 10],
34+
reference: "order-123"
35+
)
36+
37+
let data = try JSONEncoder().encode(body)
38+
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
39+
40+
let adjustments = try XCTUnwrap(json["adjustments"] as? [String: Int])
41+
expect(adjustments) == ["GLD": 50, "SLV": 10]
42+
expect(json["reference"] as? String) == "order-123"
43+
}
44+
45+
func testBodyOmitsReferenceKeyWhenNil() throws {
46+
let body = SpendVirtualCurrenciesOperation.Body(
47+
adjustments: ["GLD": 50],
48+
reference: nil
49+
)
50+
51+
let data = try JSONEncoder().encode(body)
52+
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
53+
54+
expect(json["adjustments"] as? [String: Int]) == ["GLD": 50]
55+
expect(json.keys.contains("reference")).to(beFalse())
56+
}
57+
58+
func testBodyEncodesEmptyAdjustments() throws {
59+
let body = SpendVirtualCurrenciesOperation.Body(adjustments: [:], reference: nil)
60+
61+
let data = try JSONEncoder().encode(body)
62+
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
63+
64+
expect(json["adjustments"] as? [String: Int]) == [:]
65+
}
66+
67+
}

Tests/UnitTests/Purchasing/Purchases/PurchasesVirtualCurrenciesTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,90 @@ class PurchasesVirtualCurrenciesTests: BasePurchasesTests {
139139
expect(self.mockVirtualCurrencyManager.cachedVirtualCurrenciesCallCount).to(equal(1))
140140
expect(Thread.isMainThread).to(beTrue())
141141
}
142+
143+
// MARK: - spendVirtualCurrencies() Tests
144+
145+
func testSpendVirtualCurrenciesThrowsUnsupportedErrorWhenIAMIsNotEnabled() async throws {
146+
// `self.tokenManager` defaults to disabled in `BasePurchasesTests.setUpWithError()`.
147+
var thrown: Error?
148+
do {
149+
_ = try await self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil)
150+
} catch {
151+
thrown = error
152+
}
153+
154+
expect(thrown).to(matchError(ErrorCode.unsupportedError))
155+
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beFalse())
156+
}
157+
158+
func testSpendVirtualCurrencyThrowsUnsupportedErrorWhenIAMIsNotEnabled() async throws {
159+
var thrown: Error?
160+
do {
161+
_ = try await self.purchases.spendVirtualCurrency(code: "GLD", amount: 10, reference: nil)
162+
} catch {
163+
thrown = error
164+
}
165+
166+
expect(thrown).to(matchError(ErrorCode.unsupportedError))
167+
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beFalse())
168+
}
169+
170+
func testSpendVirtualCurrenciesForwardsSuccessWhenIAMIsEnabled() async throws {
171+
self.tokenManager = MockTokenManager(enabled: true)
172+
self.setupPurchases()
173+
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
174+
175+
let vcs = try await self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: "ref-1")
176+
177+
expect(vcs).to(equal(Self.mockVirtualCurrencies))
178+
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCalled).to(beTrue())
179+
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCallCount).to(equal(1))
180+
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.amounts)
181+
== ["GLD": 10]
182+
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.reference)
183+
== "ref-1"
184+
}
185+
186+
func testSpendVirtualCurrenciesForwardsErrorWhenIAMIsEnabled() async throws {
187+
self.tokenManager = MockTokenManager(enabled: true)
188+
self.setupPurchases()
189+
let backendError: BackendError = .networkError(.offlineConnection())
190+
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .failure(backendError)
191+
192+
do {
193+
_ = try await self.purchases.spendVirtualCurrencies(amounts: ["GLD": 10], reference: nil)
194+
fail("An error should have been thrown")
195+
} catch {
196+
expect(error).to(matchError(backendError.asPurchasesError))
197+
}
198+
}
199+
200+
func testSpendVirtualCurrencyConvenienceMethodForwardsSingleAmountWhenIAMIsEnabled() async throws {
201+
self.tokenManager = MockTokenManager(enabled: true)
202+
self.setupPurchases()
203+
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .success(Self.mockVirtualCurrencies)
204+
205+
let vcs = try await self.purchases.spendVirtualCurrency(code: "GLD", amount: 25, reference: "ref-2")
206+
207+
expect(vcs).to(equal(Self.mockVirtualCurrencies))
208+
expect(self.mockVirtualCurrencyManager.spendVirtualCurrenciesCallCount).to(equal(1))
209+
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.amounts)
210+
== ["GLD": 25]
211+
expect(self.mockVirtualCurrencyManager.invokedSpendVirtualCurrenciesParametersList.first?.reference)
212+
== "ref-2"
213+
}
214+
215+
func testSpendVirtualCurrencyConvenienceMethodForwardsErrorWhenIAMIsEnabled() async throws {
216+
self.tokenManager = MockTokenManager(enabled: true)
217+
self.setupPurchases()
218+
let backendError: BackendError = .networkError(.offlineConnection())
219+
self.mockVirtualCurrencyManager.stubbedSpendVirtualCurrenciesResult = .failure(backendError)
220+
221+
do {
222+
_ = try await self.purchases.spendVirtualCurrency(code: "GLD", amount: 25, reference: nil)
223+
fail("An error should have been thrown")
224+
} catch {
225+
expect(error).to(matchError(backendError.asPurchasesError))
226+
}
227+
}
142228
}

0 commit comments

Comments
 (0)