mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
feat(ads): cache customer info from verified reward polls instead of refetching (#8166)
* feat(ads): cache customer info from verified reward polls instead of refetching * fix(ads): cache reward poll customer info under the user it was polled for * refactor(ads): require customerInfo on Outcome.Verified * refactor(ads): require customerInfo on RewardVerificationPollStatus.Verified * refactor(ads): require customerInfo on RewardVerificationStatusResponse.init * docs(ads): describe pollRewardVerification as updating state from the response * fix(ads): poll reward verification for the user captured before polling * fix(ads): reword malformed customer info log now that the poll no longer refetches * fix(ads): treat a null subscriber in the reward poll as no customer info * refactor(ads): pass appUserID through the poll run instead of storing it in the poller * refactor(ads): drop async from rewardVerificationResult * test(ads): assert the null subscriber guard skips the malformed customer info warning * test(ads): assert the polled appUserID stays fixed across attempts * docs(ads): fix the fetchRewardVerificationStatus reference in the poller doc comment
This commit is contained in:
1 parent
0dfc773979
commit
b5530243e1
14 files changed
+514
-182
No files matched your search
+4
-1
@@ -108,7 +108,10 @@ private extension GetRewardVerificationStatusOperation {
|
||||
) { callback in
|
||||
callback.completion(
|
||||
response
|
||||
.map { $0.body }
|
||||
.map {
|
||||
$0.body.copy(with: $0.verificationResult.result,
|
||||
httpResponseOriginalSource: $0.originalSource)
|
||||
}
|
||||
.mapError(BackendError.networkError)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ struct RewardVerificationStatusResponse: Equatable {
|
||||
|
||||
let status: Status
|
||||
|
||||
/// `customerInfo` is present only when the backend embeds subscriber info with an entitlement grant.
|
||||
let customerInfo: CustomerInfo?
|
||||
|
||||
init(status: Status, customerInfo: CustomerInfo?) {
|
||||
self.status = status
|
||||
self.customerInfo = customerInfo
|
||||
}
|
||||
|
||||
enum Status: Equatable {
|
||||
|
||||
/// `moreRewards` contains additional rewards only; it does not repeat `reward`.
|
||||
@@ -50,6 +58,7 @@ extension RewardVerificationStatusResponse: Decodable {
|
||||
case failureReason
|
||||
case message
|
||||
case moreRewards
|
||||
case subscriber
|
||||
}
|
||||
|
||||
private enum RewardCodingKeys: String, CodingKey {
|
||||
@@ -69,6 +78,24 @@ extension RewardVerificationStatusResponse: Decodable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawStatus = try container.decode(String.self, forKey: .status)
|
||||
self.status = Self.decodeStatus(rawStatus, from: container)
|
||||
self.customerInfo = Self.decodeCustomerInfo(from: decoder, container: container)
|
||||
}
|
||||
|
||||
private static func decodeCustomerInfo(
|
||||
from decoder: Decoder,
|
||||
container: KeyedDecodingContainer<CodingKeys>
|
||||
) -> CustomerInfo? {
|
||||
guard container.contains(.subscriber),
|
||||
(try? container.decodeNil(forKey: .subscriber)) != true else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try CustomerInfo(from: decoder)
|
||||
} catch {
|
||||
Logger.warn(Strings.backendError.malformed_reward_verification_customer_info(error: error))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeStatus(
|
||||
@@ -186,4 +213,23 @@ extension RewardVerificationStatusResponse: HTTPResponseBody {
|
||||
static func create(with data: Data) throws -> Self {
|
||||
return try JSONDecoder.default.decode(Self.self, from: data)
|
||||
}
|
||||
|
||||
func copy(with newRequestDate: Date) -> Self {
|
||||
guard let customerInfo = self.customerInfo else { return self }
|
||||
|
||||
return .init(status: self.status, customerInfo: customerInfo.copy(with: newRequestDate))
|
||||
}
|
||||
|
||||
func copy(
|
||||
with entitlementVerification: VerificationResult,
|
||||
httpResponseOriginalSource: HTTPResponseOriginalSource
|
||||
) -> Self {
|
||||
guard let customerInfo = self.customerInfo else { return self }
|
||||
|
||||
return .init(
|
||||
status: self.status,
|
||||
customerInfo: customerInfo.copy(with: entitlementVerification,
|
||||
httpResponseOriginalSource: httpResponseOriginalSource)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ internal extension RewardVerification {
|
||||
|
||||
/// Terminal SSV verdict from the polling loop.
|
||||
enum Outcome: Sendable {
|
||||
case verified(reward: AdReward, moreRewards: [AdReward])
|
||||
case verified(reward: AdReward, moreRewards: [AdReward], customerInfo: CustomerInfo?)
|
||||
case failed(FailureReason)
|
||||
|
||||
var logDescription: String {
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Production wiring: `Purchases.shared.fetchRewardVerificationStatus(clientTransactionID:)`,
|
||||
/// Production wiring: `Purchases.shared.fetchRewardVerificationStatus(clientTransactionID:adUnitID:appUserID:)`,
|
||||
/// which throws a structured ``BackendError`` so the poller can reuse the SDK's retry
|
||||
/// classification (``BackendError/isTransient``).
|
||||
internal protocol RewardVerificationStatusPolling: Sendable {
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus
|
||||
}
|
||||
|
||||
/// Async sleep abstraction used by the polling loop. Production wiring is `RewardVerification.TaskSleeper`.
|
||||
@@ -75,7 +75,7 @@ internal extension RewardVerification {
|
||||
)
|
||||
}
|
||||
|
||||
func run(clientTransactionID: String) async -> Outcome {
|
||||
func run(clientTransactionID: String, appUserID: String) async -> Outcome {
|
||||
Logger.debug(AdsStrings.poll_start(
|
||||
transactionID: clientTransactionID,
|
||||
maxAttempts: self.maxAttempts
|
||||
@@ -98,7 +98,7 @@ internal extension RewardVerification {
|
||||
try? await self.sleeper.sleep(seconds: self.jitter.sample())
|
||||
}
|
||||
|
||||
switch await self.pollOnce(clientTransactionID: clientTransactionID) {
|
||||
switch await self.pollOnce(clientTransactionID: clientTransactionID, appUserID: appUserID) {
|
||||
case .finished(let outcome):
|
||||
return outcome
|
||||
case .retry(let disposition):
|
||||
@@ -114,10 +114,11 @@ internal extension RewardVerification {
|
||||
|
||||
/// Runs a single poll attempt: either a terminal `Outcome` or a `retry` carrying the
|
||||
/// disposition to remember for exhaustion classification.
|
||||
private func pollOnce(clientTransactionID: String) async -> PollAttemptResult {
|
||||
private func pollOnce(clientTransactionID: String, appUserID: String) async -> PollAttemptResult {
|
||||
do {
|
||||
let status = try await self.statusPoller.pollStatus(
|
||||
clientTransactionID: clientTransactionID
|
||||
clientTransactionID: clientTransactionID,
|
||||
appUserID: appUserID
|
||||
)
|
||||
// A cancellation that lands while the status request is in flight won't surface as a
|
||||
// `CancellationError` if the request resolves successfully. Re-check before logging or
|
||||
@@ -132,8 +133,8 @@ internal extension RewardVerification {
|
||||
transactionID: clientTransactionID
|
||||
))
|
||||
switch status {
|
||||
case let .verified(reward, moreRewards):
|
||||
return .finished(.verified(reward: reward, moreRewards: moreRewards))
|
||||
case let .verified(reward, moreRewards, customerInfo):
|
||||
return .finished(.verified(reward: reward, moreRewards: moreRewards, customerInfo: customerInfo))
|
||||
case let .failed(reason, message):
|
||||
Logger.warn(AdsStrings.poll_backend_rejected(
|
||||
reason: reason,
|
||||
@@ -205,10 +206,11 @@ internal extension RewardVerification {
|
||||
|
||||
let adUnitID: String?
|
||||
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus {
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
try await Purchases.shared.fetchRewardVerificationStatus(
|
||||
clientTransactionID: clientTransactionID,
|
||||
adUnitID: self.adUnitID
|
||||
adUnitID: self.adUnitID,
|
||||
appUserID: appUserID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import Foundation
|
||||
enum RewardVerificationPollStatus: Sendable, Equatable {
|
||||
|
||||
/// `moreRewards` contains additional rewards only; it does not repeat `reward`.
|
||||
case verified(reward: AdReward, moreRewards: [AdReward])
|
||||
/// `customerInfo` is the subscriber info the backend embedded with the grant, when present.
|
||||
case verified(reward: AdReward, moreRewards: [AdReward], customerInfo: CustomerInfo?)
|
||||
|
||||
/// Verification has not yet completed; the caller should keep polling.
|
||||
case pending
|
||||
|
||||
@@ -40,8 +40,7 @@ enum AdsStrings {
|
||||
case poll_cancelled(transactionID: String)
|
||||
case reward_verification_completed(result: RewardVerificationResult, transactionID: String)
|
||||
case reward_verification_virtual_currency_invalidating_cache(transactionID: String)
|
||||
case reward_verification_entitlement_fetching_customer_info(transactionID: String)
|
||||
case reward_verification_entitlement_customer_info_refresh_failed(transactionID: String)
|
||||
case reward_verification_entitlement_caching_customer_info(transactionID: String)
|
||||
|
||||
}
|
||||
|
||||
@@ -111,12 +110,8 @@ extension AdsStrings: LogMessage {
|
||||
case let .reward_verification_virtual_currency_invalidating_cache(transactionID):
|
||||
return "Reward verification granted a virtual currency; invalidating virtual currencies cache " +
|
||||
"transactionID=\(transactionID)"
|
||||
case let .reward_verification_entitlement_fetching_customer_info(transactionID):
|
||||
return "Reward verification granted an entitlement; fetching updated CustomerInfo " +
|
||||
"transactionID=\(transactionID)"
|
||||
case let .reward_verification_entitlement_customer_info_refresh_failed(transactionID):
|
||||
return "Reward verification granted an entitlement but refreshing CustomerInfo failed; " +
|
||||
"reporting the reward as failed (the grant persists server-side and will sync later). " +
|
||||
case let .reward_verification_entitlement_caching_customer_info(transactionID):
|
||||
return "Reward verification granted an entitlement; caching the CustomerInfo from the response " +
|
||||
"transactionID=\(transactionID)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ enum BackendErrorStrings {
|
||||
case unsupported_reward_verification_reward_type(type: String)
|
||||
case malformed_reward_verification_reward_payload(type: String)
|
||||
case unexpected_reward_verification_reward_value
|
||||
case malformed_reward_verification_customer_info(error: Error)
|
||||
case unknown_workflow_trigger_type(type: String)
|
||||
case unknown_workflow_trigger_action_type(type: String)
|
||||
case malformed_workflow_trigger_action(type: String)
|
||||
@@ -58,6 +59,9 @@ extension BackendErrorStrings: LogMessage {
|
||||
return "Received malformed reward verification reward payload for type: \(type)"
|
||||
case .unexpected_reward_verification_reward_value:
|
||||
return "Received unexpected reward verification reward value: expected a JSON object"
|
||||
case let .malformed_reward_verification_customer_info(error):
|
||||
return "Ignoring malformed customer info in reward verification response, " +
|
||||
"local CustomerInfo may be stale until the next fetch: \(error)"
|
||||
case let .unknown_workflow_trigger_type(type):
|
||||
return "Received unknown workflow trigger type: \(type)"
|
||||
case let .unknown_workflow_trigger_action_type(type):
|
||||
|
||||
@@ -2148,7 +2148,7 @@ extension Purchases {
|
||||
/// ``generateRewardVerificationToken(impressionId:)``. Pass `trackingMetadata` to have the SDK
|
||||
/// automatically track the reward events as verification progresses
|
||||
///
|
||||
/// Refreshes local reward state before returning verified rewards.
|
||||
/// Updates local reward state from the verification response before returning verified rewards.
|
||||
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
|
||||
public func pollRewardVerification(
|
||||
clientTransactionID: String,
|
||||
@@ -2188,14 +2188,21 @@ extension Purchases {
|
||||
captureMethod: captureMethod
|
||||
)
|
||||
|
||||
let outcome = await poller.run(clientTransactionID: clientTransactionID)
|
||||
// Captured once so the poll requests and the cached `CustomerInfo` always refer to the same
|
||||
// user, even if `logIn` lands mid-poll.
|
||||
let appUserID = self.appUserID
|
||||
let outcome = await poller.run(clientTransactionID: clientTransactionID, appUserID: appUserID)
|
||||
self.trackRewardOutcome(
|
||||
outcome,
|
||||
trackingMetadata: trackingMetadata,
|
||||
captureMethod: captureMethod
|
||||
)
|
||||
|
||||
let result = await self.rewardVerificationResult(for: outcome, clientTransactionID: clientTransactionID)
|
||||
let result = self.rewardVerificationResult(
|
||||
for: outcome,
|
||||
clientTransactionID: clientTransactionID,
|
||||
appUserID: appUserID
|
||||
)
|
||||
Logger.info(AdsStrings.reward_verification_completed(
|
||||
result: result,
|
||||
transactionID: clientTransactionID
|
||||
@@ -2233,7 +2240,7 @@ extension Purchases {
|
||||
#available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) else { return }
|
||||
|
||||
switch outcome {
|
||||
case let .verified(reward, moreRewards):
|
||||
case let .verified(reward, moreRewards, _):
|
||||
let verified = AdRewardVerified(
|
||||
networkName: trackingMetadata.networkName,
|
||||
mediatorName: trackingMetadata.mediatorName,
|
||||
@@ -2273,10 +2280,11 @@ extension Purchases {
|
||||
/// Applies local side effects before building the result delivered to the caller.
|
||||
private func rewardVerificationResult(
|
||||
for outcome: RewardVerification.Outcome,
|
||||
clientTransactionID: String
|
||||
) async -> RewardVerificationResult {
|
||||
clientTransactionID: String,
|
||||
appUserID: String
|
||||
) -> RewardVerificationResult {
|
||||
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
||||
if case let .verified(reward, moreRewards) = outcome {
|
||||
if case let .verified(reward, moreRewards, customerInfo) = outcome {
|
||||
let rewards = [reward] + moreRewards
|
||||
if rewards.contains(where: { $0.virtualCurrency != nil }) {
|
||||
Logger.debug(AdsStrings.reward_verification_virtual_currency_invalidating_cache(
|
||||
@@ -2284,38 +2292,24 @@ extension Purchases {
|
||||
))
|
||||
self.invalidateVirtualCurrenciesCache()
|
||||
}
|
||||
if rewards.contains(where: { $0.entitlement != nil }),
|
||||
await self.refreshCustomerInfoAfterEntitlementGrant(clientTransactionID: clientTransactionID)
|
||||
== false {
|
||||
// Do not surface entitlement rewards that could not be reflected locally yet.
|
||||
return .failed
|
||||
if rewards.contains(where: { $0.entitlement != nil }) {
|
||||
if let customerInfo {
|
||||
Logger.debug(AdsStrings.reward_verification_entitlement_caching_customer_info(
|
||||
transactionID: clientTransactionID
|
||||
))
|
||||
self.customerInfoManager.cache(customerInfo: customerInfo, appUserID: appUserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
switch outcome {
|
||||
case let .verified(reward, moreRewards):
|
||||
case let .verified(reward, moreRewards, _):
|
||||
return .verified(reward, moreRewards: moreRewards)
|
||||
case .failed:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
#if !ENABLE_CUSTOM_ENTITLEMENT_COMPUTATION
|
||||
/// Refreshes `CustomerInfo` after entitlement grants, retrying transient failures.
|
||||
private func refreshCustomerInfoAfterEntitlementGrant(clientTransactionID: String) async -> Bool {
|
||||
Logger.debug(AdsStrings.reward_verification_entitlement_fetching_customer_info(
|
||||
transactionID: clientTransactionID
|
||||
))
|
||||
let refreshed = await self.fetchCustomerInfoRetryingTransientErrors(appUserID: self.appUserID)
|
||||
if refreshed == nil {
|
||||
Logger.warn(AdsStrings.reward_verification_entitlement_customer_info_refresh_failed(
|
||||
transactionID: clientTransactionID
|
||||
))
|
||||
}
|
||||
return refreshed != nil
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Reward Verification
|
||||
@@ -2330,11 +2324,12 @@ extension Purchases {
|
||||
/// - Throws: `BackendError`
|
||||
internal func fetchRewardVerificationStatus(
|
||||
clientTransactionID: String,
|
||||
adUnitID: String?
|
||||
adUnitID: String?,
|
||||
appUserID: String
|
||||
) async throws -> RewardVerificationPollStatus {
|
||||
let response = try await Async.call { completion in
|
||||
self.backend.adsAPI.getRewardVerificationStatus(
|
||||
appUserID: self.appUserID,
|
||||
appUserID: appUserID,
|
||||
clientTransactionID: clientTransactionID,
|
||||
adUnitID: adUnitID,
|
||||
completion: completion
|
||||
@@ -2343,7 +2338,7 @@ extension Purchases {
|
||||
|
||||
switch response.status {
|
||||
case let .verified(reward, moreRewards):
|
||||
return .verified(reward: reward, moreRewards: moreRewards)
|
||||
return .verified(reward: reward, moreRewards: moreRewards, customerInfo: response.customerInfo)
|
||||
case .pending:
|
||||
return .pending
|
||||
case let .failed(failure):
|
||||
|
||||
@@ -20,7 +20,7 @@ final class RewardVerificationOutcomeTests: TestCase {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 5))
|
||||
let outcome = RewardVerification.Outcome.verified(.virtualCurrency(reward))
|
||||
|
||||
guard case let .verified(adReward, moreRewards) = outcome, let captured = adReward.virtualCurrency else {
|
||||
guard case let .verified(adReward, moreRewards, _) = outcome, let captured = adReward.virtualCurrency else {
|
||||
return XCTFail("Expected .verified(.virtualCurrency), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(captured, reward)
|
||||
@@ -32,7 +32,7 @@ final class RewardVerificationOutcomeTests: TestCase {
|
||||
func testVerifiedCarriesNoRewardPayload() {
|
||||
let outcome = RewardVerification.Outcome.verified(.noReward)
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ final class RewardVerificationOutcomeTests: TestCase {
|
||||
func testVerifiedCarriesUnsupportedRewardPayload() {
|
||||
let outcome = RewardVerification.Outcome.verified(.unsupportedReward)
|
||||
|
||||
guard case .verified(.unsupportedReward, _) = outcome else {
|
||||
guard case .verified(.unsupportedReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.unsupportedReward), got \(outcome)")
|
||||
}
|
||||
}
|
||||
@@ -64,10 +64,11 @@ final class RewardVerificationOutcomeTests: TestCase {
|
||||
let entitlement = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let outcome = RewardVerification.Outcome.verified(
|
||||
reward: .virtualCurrency(primary),
|
||||
moreRewards: [.entitlement(entitlement)]
|
||||
moreRewards: [.entitlement(entitlement)],
|
||||
customerInfo: nil
|
||||
)
|
||||
|
||||
guard case let .verified(adReward, moreRewards) = outcome else {
|
||||
guard case let .verified(adReward, moreRewards, _) = outcome else {
|
||||
return XCTFail("Expected .verified, got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(adReward.virtualCurrency, primary)
|
||||
|
||||
@@ -25,7 +25,7 @@ final class StubStatusPoller: RewardVerificationStatusPolling, @unchecked Sendab
|
||||
self.statuses = statuses
|
||||
}
|
||||
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus {
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
self.receivedIDs.append(clientTransactionID)
|
||||
guard !self.statuses.isEmpty else {
|
||||
XCTFail("Unexpected extra pollStatus call for id=\(clientTransactionID)")
|
||||
@@ -44,7 +44,7 @@ final class ThrowingStatusPoller: RewardVerificationStatusPolling, @unchecked Se
|
||||
self.error = error
|
||||
}
|
||||
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus {
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
self.callCount += 1
|
||||
throw self.error
|
||||
}
|
||||
@@ -55,7 +55,7 @@ final class HangingStatusPoller: RewardVerificationStatusPolling, @unchecked Sen
|
||||
|
||||
private(set) var callCount = 0
|
||||
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus {
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
self.callCount += 1
|
||||
try await Task.sleep(nanoseconds: UInt64.max)
|
||||
return .pending
|
||||
@@ -79,7 +79,7 @@ final class CancelThenSucceedStatusPoller: RewardVerificationStatusPolling, @unc
|
||||
self.successStatus = successStatus
|
||||
}
|
||||
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus {
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
self.callCount += 1
|
||||
self.started.value = true
|
||||
// Wait for cancellation cooperatively *without* throwing, then resolve successfully.
|
||||
@@ -104,7 +104,7 @@ final class ScriptedStatusPoller: RewardVerificationStatusPolling, @unchecked Se
|
||||
self.steps = steps
|
||||
}
|
||||
|
||||
func pollStatus(clientTransactionID: String) async throws -> RewardVerificationPollStatus {
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
self.callCount += 1
|
||||
guard !self.steps.isEmpty else {
|
||||
XCTFail("Unexpected extra pollStatus call for id=\(clientTransactionID)")
|
||||
@@ -199,13 +199,13 @@ func makeTerminalBackendError() -> BackendError {
|
||||
|
||||
extension RewardVerificationPollStatus {
|
||||
static func verified(_ reward: AdReward) -> Self {
|
||||
.verified(reward: reward, moreRewards: [])
|
||||
.verified(reward: reward, moreRewards: [], customerInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension RewardVerification.Outcome {
|
||||
static func verified(_ reward: AdReward) -> Self {
|
||||
.verified(reward: reward, moreRewards: [])
|
||||
.verified(reward: reward, moreRewards: [], customerInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case let .verified(adReward, _) = outcome, let earnedReward = adReward.virtualCurrency else {
|
||||
guard case let .verified(adReward, _, _) = outcome, let earnedReward = adReward.virtualCurrency else {
|
||||
return XCTFail("Expected .verified(.virtualCurrency), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(earnedReward, reward)
|
||||
@@ -41,7 +41,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.backendRejected(nil, nil)) = outcome else {
|
||||
return XCTFail("Expected .failed(.backendRejected(nil, nil)), got \(outcome)")
|
||||
@@ -58,7 +58,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case let .failed(.backendRejected(reason, message)) = outcome else {
|
||||
return XCTFail("Expected .failed(.backendRejected), got \(outcome)")
|
||||
@@ -76,7 +76,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.backendRejected("no_access", nil)) = outcome else {
|
||||
return XCTFail("Expected .failed(.backendRejected(\"no_access\", nil)), got \(outcome)")
|
||||
@@ -88,9 +88,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.receivedIDs, ["tx-1", "tx-1", "tx-1"])
|
||||
@@ -102,7 +102,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 10)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.exhaustedPending) = outcome else {
|
||||
return XCTFail("Expected .failed(.exhaustedPending), got \(outcome)")
|
||||
@@ -118,9 +118,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 10)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.receivedIDs.count, 10)
|
||||
@@ -134,7 +134,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 10)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.backendRejected) = outcome else {
|
||||
return XCTFail("Expected .failed(.backendRejected), got \(outcome)")
|
||||
@@ -148,9 +148,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.receivedIDs.count, 3)
|
||||
@@ -164,7 +164,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 5)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.unexpectedResponse) = outcome else {
|
||||
return XCTFail("Expected .failed(.unexpectedResponse), got \(outcome)")
|
||||
@@ -179,7 +179,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 3)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.exhaustedPending) = outcome else {
|
||||
return XCTFail("Expected .failed(.exhaustedPending), got \(outcome)")
|
||||
@@ -196,9 +196,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 2)
|
||||
@@ -214,9 +214,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 3)
|
||||
@@ -230,7 +230,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 5)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.exhaustedTransient) = outcome else {
|
||||
return XCTFail(
|
||||
@@ -255,7 +255,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 5)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.exhaustedPending) = outcome else {
|
||||
return XCTFail("Expected .failed(.exhaustedPending), got \(outcome)")
|
||||
@@ -277,9 +277,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
])
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: RecordingSleeper())
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified for transient error \(error), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 2, "Transient error \(error) should have been retried once")
|
||||
@@ -296,9 +296,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 2, "A 5xx should be retried")
|
||||
@@ -313,9 +313,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 2, "An unparseable 5xx should still be retried")
|
||||
@@ -329,7 +329,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -343,7 +343,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -356,7 +356,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -373,9 +373,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward), got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 3)
|
||||
@@ -388,7 +388,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -403,7 +403,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -419,7 +419,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -436,7 +436,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.cancelled) = outcome else {
|
||||
return XCTFail("Expected .failed(.cancelled), got \(outcome)")
|
||||
@@ -451,7 +451,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.terminalError) = outcome else {
|
||||
return XCTFail("Expected .failed(.terminalError), got \(outcome)")
|
||||
@@ -465,7 +465,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 10)
|
||||
|
||||
let task = Task<RewardVerification.Outcome, Never> {
|
||||
await sut.run(clientTransactionID: "tx-1")
|
||||
await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
}
|
||||
task.cancel()
|
||||
let outcome = await task.value
|
||||
@@ -488,7 +488,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let task = Task<RewardVerification.Outcome, Never> {
|
||||
await sut.run(clientTransactionID: "tx-1")
|
||||
await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
}
|
||||
// Cancel only once the poll is in flight, so cancellation interrupts a started request.
|
||||
while !started.value {
|
||||
@@ -508,9 +508,9 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = ThrowingSleeper(error: PollerSentinelError())
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .verified(.noReward, _) = outcome else {
|
||||
guard case .verified(.noReward, _, _) = outcome else {
|
||||
return XCTFail("Expected .verified(.noReward) after sleeper failure was swallowed, got \(outcome)")
|
||||
}
|
||||
XCTAssertEqual(statusPoller.callCount, 2)
|
||||
@@ -524,7 +524,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 0)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.exhaustedPending) = outcome else {
|
||||
return XCTFail("Expected .failed(.exhaustedPending), got \(outcome)")
|
||||
@@ -538,7 +538,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
let sleeper = RecordingSleeper()
|
||||
let sut = makePoller(statusPoller: statusPoller, sleeper: sleeper, maxAttempts: 1)
|
||||
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1")
|
||||
let outcome = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
guard case .failed(.exhaustedPending) = outcome else {
|
||||
return XCTFail("Expected .failed(.exhaustedPending), got \(outcome)")
|
||||
@@ -576,7 +576,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
maxAttempts: 10
|
||||
)
|
||||
|
||||
_ = await sut.run(clientTransactionID: "tx-1")
|
||||
_ = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
XCTAssertEqual(sleeper.delays.count, 9)
|
||||
for delay in sleeper.delays {
|
||||
@@ -600,7 +600,7 @@ final class RewardVerificationPollerTests: TestCase {
|
||||
maxAttempts: 5
|
||||
)
|
||||
|
||||
_ = await sut.run(clientTransactionID: "tx-1")
|
||||
_ = await sut.run(clientTransactionID: "tx-1", appUserID: "user-1")
|
||||
|
||||
XCTAssertEqual(counter.value, 4)
|
||||
XCTAssertEqual(sleeper.delays.count, 4)
|
||||
|
||||
@@ -231,6 +231,34 @@ final class BackendGetRewardVerificationStatusTests: BaseBackendTests {
|
||||
)
|
||||
}
|
||||
|
||||
func testGetRewardVerificationStatusStampsEmbeddedCustomerInfoWithVerificationResult() throws {
|
||||
self.httpClient.disableSnapshotTesting()
|
||||
self.httpClient.mock(
|
||||
requestPath: .rewardVerificationStatus(
|
||||
appUserID: Self.userID,
|
||||
clientTransactionID: Self.clientTransactionID,
|
||||
adUnitID: nil
|
||||
),
|
||||
response: .init(statusCode: .success,
|
||||
response: Self.verifiedWithSubscriberResponse,
|
||||
verificationResult: .verified)
|
||||
)
|
||||
|
||||
let result = waitUntilValue { completed in
|
||||
self.adsAPI.getRewardVerificationStatus(
|
||||
appUserID: Self.userID,
|
||||
clientTransactionID: Self.clientTransactionID,
|
||||
adUnitID: nil,
|
||||
completion: completed
|
||||
)
|
||||
}
|
||||
|
||||
let customerInfo = try XCTUnwrap(result?.value?.customerInfo)
|
||||
expect(customerInfo.originalAppUserId) == Self.userID
|
||||
expect(customerInfo.entitlements.verification) == .verified
|
||||
expect(customerInfo.entitlements["pro"]?.verification) == .verified
|
||||
}
|
||||
|
||||
// MARK: - Error handling
|
||||
|
||||
func testGetRewardVerificationStatusFailSendsError() {
|
||||
@@ -447,6 +475,37 @@ private extension BackendGetRewardVerificationStatusTests {
|
||||
"amount": 10
|
||||
]
|
||||
]
|
||||
static let verifiedWithSubscriberResponse: [String: Any] = [
|
||||
"status": "verified",
|
||||
"reward": [
|
||||
"type": "entitlement",
|
||||
"identifier": "pro",
|
||||
"expires_at": "2026-06-16T12:00:00Z"
|
||||
],
|
||||
"more_rewards": [] as [Any],
|
||||
"request_date": "2026-05-16T12:00:00Z",
|
||||
"request_date_ms": 1778932800000,
|
||||
"subscriber": [
|
||||
"first_seen": "2026-05-16T12:00:00Z",
|
||||
"original_app_user_id": BaseBackendTests.userID,
|
||||
"other_purchases": [:] as [String: Any],
|
||||
"subscriptions": [
|
||||
"rc_ad_pro": [
|
||||
"purchase_date": "2026-05-16T12:00:00Z",
|
||||
"expires_date": "2026-06-16T12:00:00Z",
|
||||
"period_type": "normal",
|
||||
"store": "promotional"
|
||||
]
|
||||
],
|
||||
"entitlements": [
|
||||
"pro": [
|
||||
"product_identifier": "rc_ad_pro",
|
||||
"purchase_date": "2026-05-16T12:00:00Z",
|
||||
"expires_date": "2026-06-16T12:00:00Z"
|
||||
]
|
||||
]
|
||||
] as [String: Any]
|
||||
]
|
||||
static let pendingResponse: [String: Any] = ["status": "pending"]
|
||||
static let failedResponse: [String: Any] = ["status": "failed"]
|
||||
static let failedWithReasonResponse: [String: Any] = [
|
||||
|
||||
+108
@@ -259,6 +259,114 @@ final class RewardVerificationStatusResponseDecodingTests: TestCase {
|
||||
expect(response.status) == .pending
|
||||
}
|
||||
|
||||
// MARK: - Embedded customer info
|
||||
|
||||
func testDecodesVerifiedWithSubscriberAsCustomerInfo() throws {
|
||||
let response = try Self.decode(Self.verifiedWithSubscriber)
|
||||
|
||||
let customerInfo = try XCTUnwrap(response.customerInfo)
|
||||
expect(customerInfo.originalAppUserId) == "user-1"
|
||||
expect(customerInfo.entitlements["pro"]).toNot(beNil())
|
||||
expect(customerInfo.entitlements.verification) == .notRequested
|
||||
}
|
||||
|
||||
func testDecodesVerifiedWithoutSubscriberAsNilCustomerInfo() throws {
|
||||
let response = try Self.decode([
|
||||
"status": "verified",
|
||||
"reward": Self.entitlementReward
|
||||
])
|
||||
|
||||
expect(response.customerInfo).to(beNil())
|
||||
}
|
||||
|
||||
func testDecodesVerifiedWithNullSubscriberAsNilCustomerInfo() throws {
|
||||
let response = try Self.decode([
|
||||
"status": "verified",
|
||||
"reward": Self.entitlementReward,
|
||||
"subscriber": NSNull()
|
||||
])
|
||||
|
||||
expect(response.customerInfo).to(beNil())
|
||||
expect(response.status) == .verified(reward: .entitlement(Self.entitlement), moreRewards: [])
|
||||
self.logger.verifyMessageWasNotLogged(regexPattern: "customer info", allowNoMessages: true)
|
||||
}
|
||||
|
||||
func testDecodesVerifiedWithMalformedSubscriberAsNilCustomerInfo() throws {
|
||||
let response = try Self.decode([
|
||||
"status": "verified",
|
||||
"reward": Self.entitlementReward,
|
||||
"request_date": "2026-05-16T12:00:00Z",
|
||||
"subscriber": "not_an_object"
|
||||
])
|
||||
|
||||
expect(response.customerInfo).to(beNil())
|
||||
expect(response.status) == .verified(reward: .entitlement(Self.entitlement), moreRewards: [])
|
||||
self.logger.verifyMessageWasLogged(regexPattern: "customer info", level: .warn)
|
||||
}
|
||||
|
||||
func testCopyWithVerificationResultStampsEmbeddedCustomerInfo() throws {
|
||||
let response = try Self.decode(Self.verifiedWithSubscriber)
|
||||
|
||||
let stamped = response.copy(
|
||||
with: .verified,
|
||||
httpResponseOriginalSource: .init(isFallbackUrlResponse: false, isLoadShedderResponse: true)
|
||||
)
|
||||
|
||||
expect(stamped.status) == response.status
|
||||
expect(stamped.customerInfo?.entitlements.verification) == .verified
|
||||
expect(stamped.customerInfo?.entitlements["pro"]?.verification) == .verified
|
||||
}
|
||||
|
||||
func testCopyWithVerificationResultWithoutCustomerInfoIsUnchanged() throws {
|
||||
let response = try Self.decode(["status": "verified", "reward": Self.entitlementReward])
|
||||
|
||||
let stamped = response.copy(
|
||||
with: .verified,
|
||||
httpResponseOriginalSource: .init(isFallbackUrlResponse: false, isLoadShedderResponse: false)
|
||||
)
|
||||
|
||||
expect(stamped) == response
|
||||
}
|
||||
|
||||
private static let entitlement = EntitlementReward(
|
||||
identifier: "pro",
|
||||
expiresAt: ISO8601DateFormatter().date(from: "2026-06-16T12:00:00Z")!
|
||||
)!
|
||||
|
||||
private static let entitlementReward: [String: Any] = [
|
||||
"type": "entitlement",
|
||||
"identifier": "pro",
|
||||
"expires_at": "2026-06-16T12:00:00Z"
|
||||
]
|
||||
|
||||
private static let verifiedWithSubscriber: [String: Any] = [
|
||||
"status": "verified",
|
||||
"reward": RewardVerificationStatusResponseDecodingTests.entitlementReward,
|
||||
"more_rewards": [] as [Any],
|
||||
"request_date": "2026-05-16T12:00:00Z",
|
||||
"request_date_ms": 1778932800000,
|
||||
"subscriber": [
|
||||
"first_seen": "2026-05-16T12:00:00Z",
|
||||
"original_app_user_id": "user-1",
|
||||
"other_purchases": [:] as [String: Any],
|
||||
"subscriptions": [
|
||||
"rc_ad_pro": [
|
||||
"purchase_date": "2026-05-16T12:00:00Z",
|
||||
"expires_date": "2026-06-16T12:00:00Z",
|
||||
"period_type": "normal",
|
||||
"store": "promotional"
|
||||
]
|
||||
],
|
||||
"entitlements": [
|
||||
"pro": [
|
||||
"product_identifier": "rc_ad_pro",
|
||||
"purchase_date": "2026-05-16T12:00:00Z",
|
||||
"expires_date": "2026-06-16T12:00:00Z"
|
||||
]
|
||||
]
|
||||
] as [String: Any]
|
||||
]
|
||||
|
||||
private static func decode(_ json: [String: Any]) throws -> RewardVerificationStatusResponse {
|
||||
let data = try JSONSerialization.data(withJSONObject: json)
|
||||
return try RewardVerificationStatusResponse.create(with: data)
|
||||
|
||||
@@ -31,11 +31,36 @@ final class PurchasesRewardVerificationTests: BasePurchasesTests {
|
||||
self.setupPurchases()
|
||||
}
|
||||
|
||||
private func fetchRewardVerificationStatus(
|
||||
clientTransactionID: String,
|
||||
adUnitID: String?
|
||||
) async throws -> RewardVerificationPollStatus {
|
||||
try await self.purchases.fetchRewardVerificationStatus(
|
||||
clientTransactionID: clientTransactionID,
|
||||
adUnitID: adUnitID,
|
||||
appUserID: self.identityManager.currentAppUserID
|
||||
)
|
||||
}
|
||||
|
||||
private func pollRewardVerification(
|
||||
clientTransactionID: String,
|
||||
trackingMetadata: RewardedAdTrackingMetadata? = nil,
|
||||
poller: RewardVerification.Poller
|
||||
) async -> RewardVerificationResult {
|
||||
await self.purchases.pollRewardVerification(
|
||||
clientTransactionID: clientTransactionID,
|
||||
trackingMetadata: trackingMetadata,
|
||||
poller: poller
|
||||
)
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusMapsUnknownStatusToUnknown() async throws {
|
||||
let transactionID = "AABBCCDD-1111-2222-3333-444455556666"
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(.init(status: .unknown))
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .unknown, customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(
|
||||
let status = try await self.fetchRewardVerificationStatus(
|
||||
clientTransactionID: transactionID,
|
||||
adUnitID: nil
|
||||
)
|
||||
@@ -49,58 +74,76 @@ final class PurchasesRewardVerificationTests: BasePurchasesTests {
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusForwardsAdUnitID() async throws {
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(.init(status: .pending))
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .pending, customerInfo: nil)
|
||||
)
|
||||
|
||||
_ = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: "ad-unit")
|
||||
_ = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: "ad-unit")
|
||||
|
||||
expect(try self.mockAdsAPI.invokedGetRewardVerificationStatusParameters?.adUnitID) == "ad-unit"
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusRequestsGivenAppUserID() async throws {
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .pending, customerInfo: nil)
|
||||
)
|
||||
|
||||
_ = try await self.purchases.fetchRewardVerificationStatus(
|
||||
clientTransactionID: "tx-id",
|
||||
adUnitID: nil,
|
||||
appUserID: "other-user"
|
||||
)
|
||||
|
||||
expect(try self.mockAdsAPI.invokedGetRewardVerificationStatusParameters?.appUserID) == "other-user"
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusMapsVerifiedStatusWithVirtualCurrencyReward() async throws {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 10))
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .verified(.virtualCurrency(reward)))
|
||||
.init(status: .verified(.virtualCurrency(reward)), customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .verified(.virtualCurrency(reward))
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusMapsVerifiedStatusWithNoReward() async throws {
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .verified(.noReward))
|
||||
.init(status: .verified(.noReward), customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .verified(.noReward)
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusMapsVerifiedStatusWithUnsupportedReward() async throws {
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .verified(.unsupportedReward))
|
||||
.init(status: .verified(.unsupportedReward), customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .verified(.unsupportedReward)
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusMapsPendingStatus() async throws {
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(.init(status: .pending))
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .pending, customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .pending
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusMapsFailedStatus() async throws {
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .failed(.init(reason: nil, message: nil)))
|
||||
.init(status: .failed(.init(reason: nil, message: nil)), customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .failed(reason: nil, message: nil)
|
||||
}
|
||||
@@ -110,10 +153,10 @@ final class PurchasesRewardVerificationTests: BasePurchasesTests {
|
||||
.init(status: .failed(.init(
|
||||
reason: "no_access",
|
||||
message: "AdMob server-side reward verification is not enabled for this app."
|
||||
)))
|
||||
)), customerInfo: nil)
|
||||
)
|
||||
|
||||
let status = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .failed(
|
||||
reason: "no_access",
|
||||
@@ -121,12 +164,24 @@ final class PurchasesRewardVerificationTests: BasePurchasesTests {
|
||||
)
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusForwardsCustomerInfo() async throws {
|
||||
let reward = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let customerInfo = try Self.makeCustomerInfoWithEntitlement("pro")
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .success(
|
||||
.init(status: .verified(.entitlement(reward)), customerInfo: customerInfo)
|
||||
)
|
||||
|
||||
let status = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
|
||||
expect(status) == .verified(reward: .entitlement(reward), moreRewards: [], customerInfo: customerInfo)
|
||||
}
|
||||
|
||||
func testFetchRewardVerificationStatusForwardsBackendError() async throws {
|
||||
let backendError: BackendError = .networkError(.offlineConnection())
|
||||
try self.mockAdsAPI.stubbedGetRewardVerificationStatusResult = .failure(backendError)
|
||||
|
||||
do {
|
||||
_ = try await self.purchases.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
_ = try await self.fetchRewardVerificationStatus(clientTransactionID: "tx-id", adUnitID: nil)
|
||||
fail("Expected fetchRewardVerificationStatus to throw")
|
||||
} catch {
|
||||
expect(error).to(matchError(backendError))
|
||||
@@ -147,7 +202,7 @@ extension PurchasesRewardVerificationTests {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 3))
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.virtualCurrency(reward))])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result.verifiedReward) == .virtualCurrency(reward)
|
||||
}
|
||||
@@ -155,7 +210,7 @@ extension PurchasesRewardVerificationTests {
|
||||
func testPollRewardVerificationReturnsVerifiedWithNoReward() async {
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.noReward)])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result) == .verified(.noReward)
|
||||
}
|
||||
@@ -163,7 +218,7 @@ extension PurchasesRewardVerificationTests {
|
||||
func testPollRewardVerificationReturnsVerifiedWithUnsupportedReward() async {
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.unsupportedReward)])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result) == .verified(.unsupportedReward)
|
||||
}
|
||||
@@ -171,7 +226,7 @@ extension PurchasesRewardVerificationTests {
|
||||
func testPollRewardVerificationReturnsFailed() async {
|
||||
let poller = self.makeStubPoller(statuses: [.failed(reason: nil, message: nil)])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result) == .failed
|
||||
}
|
||||
@@ -180,7 +235,7 @@ extension PurchasesRewardVerificationTests {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 4))
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.virtualCurrency(reward))])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 1
|
||||
}
|
||||
@@ -188,7 +243,7 @@ extension PurchasesRewardVerificationTests {
|
||||
func testPollRewardVerificationDoesNotInvalidateCacheOnNoReward() async {
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.noReward)])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 0
|
||||
}
|
||||
@@ -196,7 +251,7 @@ extension PurchasesRewardVerificationTests {
|
||||
func testPollRewardVerificationDoesNotInvalidateCacheOnUnsupportedReward() async {
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.unsupportedReward)])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 0
|
||||
}
|
||||
@@ -204,50 +259,84 @@ extension PurchasesRewardVerificationTests {
|
||||
func testPollRewardVerificationDoesNotInvalidateCacheOnFailed() async {
|
||||
let poller = self.makeStubPoller(statuses: [.failed(reason: nil, message: nil)])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 0
|
||||
}
|
||||
|
||||
func testPollRewardVerificationFetchesCustomerInfoOnEntitlementReward() async throws {
|
||||
func testPollRewardVerificationDoesNotFetchCustomerInfoOnEntitlementRewardWithoutCustomerInfo() async throws {
|
||||
let reward = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let before = self.backend.getCustomerInfoCallCount
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.entitlement(reward))])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result.verifiedReward) == .entitlement(reward)
|
||||
expect(self.backend.getCustomerInfoCallCount) > before
|
||||
expect(self.backend.getCustomerInfoCallCount) == before
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 0
|
||||
}
|
||||
|
||||
func testPollRewardVerificationFailsWhenEntitlementCustomerInfoRefreshFails() async throws {
|
||||
func testPollRewardVerificationCachesEmbeddedCustomerInfoOnEntitlementRewardWithoutFetching() async throws {
|
||||
let reward = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
self.backend.overrideCustomerInfoResult = .failure(makeTerminalBackendError())
|
||||
let customerInfo = try Self.makeCustomerInfoWithEntitlement("pro")
|
||||
let before = self.backend.getCustomerInfoCallCount
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.entitlement(reward))])
|
||||
let poller = self.makeStubPoller(statuses: [
|
||||
.verified(reward: .entitlement(reward), moreRewards: [], customerInfo: customerInfo)
|
||||
])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result) == .failed
|
||||
expect(result.verifiedReward).to(beNil())
|
||||
expect(self.backend.getCustomerInfoCallCount) == before + 1
|
||||
self.logger.verifyMessageWasLogged(
|
||||
AdsStrings.reward_verification_completed(result: .failed, transactionID: "tx-1"),
|
||||
level: .info
|
||||
)
|
||||
self.logger.verifyMessageWasNotLogged(
|
||||
AdsStrings.reward_verification_completed(
|
||||
result: .verified(.entitlement(reward)), transactionID: "tx-1"
|
||||
)
|
||||
)
|
||||
expect(result.verifiedReward) == .entitlement(reward)
|
||||
expect(self.backend.getCustomerInfoCallCount) == before
|
||||
expect(try self.customerInfoManager.cachedCustomerInfo(appUserID: self.identityManager.currentAppUserID))
|
||||
== customerInfo
|
||||
await expect(self.purchasesDelegate.customerInfo).toEventually(equal(customerInfo))
|
||||
expect(self.purchasesDelegate.customerInfo?.entitlements["pro"]).toNot(beNil())
|
||||
}
|
||||
|
||||
func testTransientServerErrorsAreRetriableForEntitlementRefresh() {
|
||||
for statusCode in [502, 503] {
|
||||
let error = makePollingError(statusCode: statusCode, backendCode: .internalServerError)
|
||||
expect(error.isTransient).to(beTrue(), description: "status \(statusCode) should be transient")
|
||||
}
|
||||
func testPollRewardVerificationMultiGrantCachesEmbeddedCustomerInfoWithoutFetching() async throws {
|
||||
let virtualCurrency = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 5))
|
||||
let entitlement = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let customerInfo = try Self.makeCustomerInfoWithEntitlement("pro")
|
||||
let before = self.backend.getCustomerInfoCallCount
|
||||
let poller = self.makeStubPoller(statuses: [
|
||||
.verified(reward: .virtualCurrency(virtualCurrency),
|
||||
moreRewards: [.entitlement(entitlement)],
|
||||
customerInfo: customerInfo)
|
||||
])
|
||||
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result.moreRewards) == [.entitlement(entitlement)]
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 1
|
||||
expect(self.backend.getCustomerInfoCallCount) == before
|
||||
await expect(self.purchasesDelegate.customerInfo).toEventually(equal(customerInfo))
|
||||
}
|
||||
|
||||
private static func makeCustomerInfoWithEntitlement(_ identifier: String) throws -> CustomerInfo {
|
||||
return try CustomerInfo(data: [
|
||||
"request_date": "2026-05-16T12:00:00Z",
|
||||
"subscriber": [
|
||||
"first_seen": "2026-05-16T12:00:00Z",
|
||||
"original_app_user_id": BasePurchasesTests.appUserID,
|
||||
"other_purchases": [:] as [String: Any],
|
||||
"subscriptions": [
|
||||
"rc_ad_\(identifier)": [
|
||||
"purchase_date": "2026-05-16T12:00:00Z",
|
||||
"expires_date": "2026-06-16T12:00:00Z",
|
||||
"period_type": "normal",
|
||||
"store": "promotional"
|
||||
]
|
||||
],
|
||||
"entitlements": [
|
||||
identifier: [
|
||||
"product_identifier": "rc_ad_\(identifier)",
|
||||
"purchase_date": "2026-05-16T12:00:00Z",
|
||||
"expires_date": "2026-06-16T12:00:00Z"
|
||||
]
|
||||
]
|
||||
] as [String: Any]
|
||||
])
|
||||
}
|
||||
|
||||
func testPollRewardVerificationDoesNotFetchCustomerInfoOnVirtualCurrencyReward() async throws {
|
||||
@@ -255,39 +344,66 @@ extension PurchasesRewardVerificationTests {
|
||||
let before = self.backend.getCustomerInfoCallCount
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.virtualCurrency(reward))])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(self.backend.getCustomerInfoCallCount) == before
|
||||
}
|
||||
|
||||
func testPollRewardVerificationMultiGrantInvalidatesVCCacheAndFetchesCustomerInfo() async throws {
|
||||
func testPollRewardVerificationMultiGrantInvalidatesVCCacheWithoutFetchingCustomerInfo() async throws {
|
||||
let virtualCurrency = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 5))
|
||||
let entitlement = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let before = self.backend.getCustomerInfoCallCount
|
||||
let poller = self.makeStubPoller(statuses: [
|
||||
.verified(reward: .virtualCurrency(virtualCurrency), moreRewards: [.entitlement(entitlement)])
|
||||
.verified(reward: .virtualCurrency(virtualCurrency),
|
||||
moreRewards: [.entitlement(entitlement)],
|
||||
customerInfo: nil)
|
||||
])
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
let result = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 1
|
||||
expect(self.backend.getCustomerInfoCallCount) > before
|
||||
expect(self.backend.getCustomerInfoCallCount) == before
|
||||
expect(result.verifiedReward) == .virtualCurrency(virtualCurrency)
|
||||
expect(result.moreRewards) == [.entitlement(entitlement)]
|
||||
}
|
||||
|
||||
func testPollRewardVerificationMultiGrantFailsWhenEntitlementRefreshFails() async throws {
|
||||
let virtualCurrency = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 5))
|
||||
let entitlement = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
self.backend.overrideCustomerInfoResult = .failure(makeTerminalBackendError())
|
||||
let poller = self.makeStubPoller(statuses: [
|
||||
.verified(reward: .virtualCurrency(virtualCurrency), moreRewards: [.entitlement(entitlement)])
|
||||
])
|
||||
func testPollRewardVerificationCachesEmbeddedCustomerInfoUnderUserPolledFor() async throws {
|
||||
let reward = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let customerInfo = try Self.makeCustomerInfoWithEntitlement("pro")
|
||||
let originalAppUserID = self.identityManager.currentAppUserID
|
||||
let newAppUserID = "other-user"
|
||||
let identityManager = try XCTUnwrap(self.identityManager)
|
||||
let statusPoller = UserSwitchingStatusPoller(statuses: [
|
||||
.pending,
|
||||
.verified(reward: .entitlement(reward), moreRewards: [], customerInfo: customerInfo)
|
||||
]) { identityManager.mockAppUserID = newAppUserID }
|
||||
let poller = makePoller(statusPoller: statusPoller, sleeper: RecordingSleeper())
|
||||
|
||||
let result = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
expect(result) == .failed
|
||||
expect(self.mockVirtualCurrencyManager.invalidateVirtualCurrenciesCacheCallCount) == 1
|
||||
expect(statusPoller.receivedAppUserIDs) == [originalAppUserID, originalAppUserID]
|
||||
expect(try self.customerInfoManager.cachedCustomerInfo(appUserID: originalAppUserID)) == customerInfo
|
||||
expect(try self.customerInfoManager.cachedCustomerInfo(appUserID: newAppUserID)).to(beNil())
|
||||
await expect(self.purchasesDelegate.customerInfo).toNever(equal(customerInfo), until: .milliseconds(200))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class UserSwitchingStatusPoller: RewardVerificationStatusPolling, @unchecked Sendable {
|
||||
|
||||
private var statuses: [RewardVerificationPollStatus]
|
||||
private let onPoll: () -> Void
|
||||
private(set) var receivedAppUserIDs: [String] = []
|
||||
|
||||
init(statuses: [RewardVerificationPollStatus], onPoll: @escaping () -> Void) {
|
||||
self.statuses = statuses
|
||||
self.onPoll = onPoll
|
||||
}
|
||||
|
||||
func pollStatus(clientTransactionID: String, appUserID: String) async throws -> RewardVerificationPollStatus {
|
||||
self.receivedAppUserIDs.append(appUserID)
|
||||
self.onPoll()
|
||||
return self.statuses.removeFirst()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -313,7 +429,7 @@ extension PurchasesRewardVerificationTests {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 3))
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.virtualCurrency(reward))])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
_ = await self.pollRewardVerification(clientTransactionID: "tx-1", poller: poller)
|
||||
|
||||
await expect { try await self.mockEventsManager.trackedAdEvents }.toEventually(beEmpty())
|
||||
}
|
||||
@@ -323,7 +439,7 @@ extension PurchasesRewardVerificationTests {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 3))
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.virtualCurrency(reward))])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(
|
||||
_ = await self.pollRewardVerification(
|
||||
clientTransactionID: "tx-1",
|
||||
trackingMetadata: self.makeTrackingMetadata(),
|
||||
poller: poller
|
||||
@@ -349,7 +465,7 @@ extension PurchasesRewardVerificationTests {
|
||||
let reward = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 3))
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.virtualCurrency(reward))])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(
|
||||
_ = await self.pollRewardVerification(
|
||||
clientTransactionID: "tx-1",
|
||||
trackingMetadata: self.makeTrackingMetadata(),
|
||||
poller: poller
|
||||
@@ -372,7 +488,7 @@ extension PurchasesRewardVerificationTests {
|
||||
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
|
||||
let poller = self.makeStubPoller(statuses: [.verified(.noReward)])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(
|
||||
_ = await self.pollRewardVerification(
|
||||
clientTransactionID: "tx-1",
|
||||
trackingMetadata: self.makeTrackingMetadata(),
|
||||
poller: poller
|
||||
@@ -389,10 +505,12 @@ extension PurchasesRewardVerificationTests {
|
||||
let virtualCurrency = try XCTUnwrap(VirtualCurrencyReward(code: "coins", amount: 5))
|
||||
let entitlement = try XCTUnwrap(EntitlementReward(identifier: "pro", expiresAt: Date()))
|
||||
let poller = self.makeStubPoller(statuses: [
|
||||
.verified(reward: .virtualCurrency(virtualCurrency), moreRewards: [.entitlement(entitlement)])
|
||||
.verified(reward: .virtualCurrency(virtualCurrency),
|
||||
moreRewards: [.entitlement(entitlement)],
|
||||
customerInfo: nil)
|
||||
])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(
|
||||
_ = await self.pollRewardVerification(
|
||||
clientTransactionID: "tx-1",
|
||||
trackingMetadata: self.makeTrackingMetadata(),
|
||||
poller: poller
|
||||
@@ -415,7 +533,7 @@ extension PurchasesRewardVerificationTests {
|
||||
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
|
||||
let poller = self.makeStubPoller(statuses: [.failed(reason: "no_reward_rule", message: "nope")])
|
||||
|
||||
_ = await self.purchases.pollRewardVerification(
|
||||
_ = await self.pollRewardVerification(
|
||||
clientTransactionID: "tx-1",
|
||||
trackingMetadata: self.makeTrackingMetadata(),
|
||||
poller: poller
|
||||
@@ -435,7 +553,7 @@ extension PurchasesRewardVerificationTests {
|
||||
let poller = self.makeStubPoller(statuses: [.pending, .verified(.noReward)])
|
||||
|
||||
let task = Task {
|
||||
await self.purchases.pollRewardVerification(
|
||||
await self.pollRewardVerification(
|
||||
clientTransactionID: "tx-1",
|
||||
trackingMetadata: self.makeTrackingMetadata(),
|
||||
poller: poller
|
||||
|
||||
Reference in new issue
Block a user