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:
Peter Porfy authored and GitHub committed 2026-11-09 14:51:33 +01:00
1 parent 0dfc773979
commit b5530243e1
14 files changed
+514 -182

No files matched your search

@@ -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)
)
}
}
+1 -1
View File
@@ -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 -10
View File
@@ -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
+3 -8
View File
@@ -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):
+27 -32
View File
@@ -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] = [
@@ -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