mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
test(remote-config): add backend integration tests for API sources (#8061)
* test(remote-config): add backend integration tests for API sources Co-authored-by: Cursor <cursoragent@cursor.com> * Add APISourcesIntegrationTests.swift to RevenueCat.xcodeproj Co-authored-by: Cursor <cursoragent@cursor.com> * Assert API source hosts and cover backup, exhaustion and badURL failover in tests * Test failover to the backup source through Purchases with a simulated primary outage Co-authored-by: Cursor <cursoragent@cursor.com> * Use HealthCheckAction in the simulated outage strategy Co-authored-by: Cursor <cursoragent@cursor.com> * Skip the API source outage tests in the LoadShedder, Offline, SK1 and SK2 plans Co-authored-by: Cursor <cursoragent@cursor.com> * Run every API source integration test through a normal Purchases instance Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
7671703d79
commit
addf52a93c
10 files changed
+306
-1
No files matched your search
@@ -38,6 +38,13 @@
|
||||
"testTargets" : [
|
||||
{
|
||||
"skippedTests" : [
|
||||
"APISourceOutageStoreKit1IntegrationTests",
|
||||
"APISourceOutageStoreKit2IntegrationTests",
|
||||
"APISourceServerErrorIntegrationTests",
|
||||
"APISourcesExhaustedIntegrationTests",
|
||||
"APISourcesStoreKit1IntegrationTests",
|
||||
"APISourcesStoreKit2IntegrationTests",
|
||||
"BaseAPISourcesIntegrationTests",
|
||||
"BaseBackendIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests\/testCanGetOfferings()",
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
"testTargets" : [
|
||||
{
|
||||
"skippedTests" : [
|
||||
"APISourceOutageStoreKit1IntegrationTests",
|
||||
"APISourceOutageStoreKit2IntegrationTests",
|
||||
"APISourceServerErrorIntegrationTests",
|
||||
"APISourcesExhaustedIntegrationTests",
|
||||
"APISourcesStoreKit1IntegrationTests",
|
||||
"APISourcesStoreKit2IntegrationTests",
|
||||
"BaseAPISourcesIntegrationTests",
|
||||
"BaseBackendIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests\/testCanGetOfferings()",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"testTargets" : [
|
||||
{
|
||||
"skippedTests" : [
|
||||
"BaseAPISourcesIntegrationTests",
|
||||
"BaseBackendIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests\/testCanGetOfferings()",
|
||||
|
||||
@@ -62,6 +62,13 @@
|
||||
"testTargets" : [
|
||||
{
|
||||
"skippedTests" : [
|
||||
"APISourceOutageStoreKit1IntegrationTests",
|
||||
"APISourceOutageStoreKit2IntegrationTests",
|
||||
"APISourceServerErrorIntegrationTests",
|
||||
"APISourcesExhaustedIntegrationTests",
|
||||
"APISourcesStoreKit1IntegrationTests",
|
||||
"APISourcesStoreKit2IntegrationTests",
|
||||
"BaseAPISourcesIntegrationTests",
|
||||
"BaseBackendIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests\/testCanGetOfferings()",
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
"testTargets" : [
|
||||
{
|
||||
"skippedTests" : [
|
||||
"APISourceOutageStoreKit1IntegrationTests",
|
||||
"APISourceOutageStoreKit2IntegrationTests",
|
||||
"APISourceServerErrorIntegrationTests",
|
||||
"APISourcesExhaustedIntegrationTests",
|
||||
"APISourcesStoreKit1IntegrationTests",
|
||||
"APISourcesStoreKit2IntegrationTests",
|
||||
"BaseAPISourcesIntegrationTests",
|
||||
"BaseBackendIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests",
|
||||
"BaseLoadShedderStoreKitIntegrationTests\/testCanGetOfferings()",
|
||||
|
||||
@@ -3268,6 +3268,7 @@
|
||||
A1B2C3D42FE3000000000001 /* RemoteConfigFetchContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigFetchContext.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42FE300000000001 /* ProductionRemoteConfigIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductionRemoteConfigIntegrationTests.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42FE300000000002 /* WorkflowComponentsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowComponentsIntegrationTests.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42FE300000000003 /* APISourcesIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APISourcesIntegrationTests.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42FE4000000000001 /* RCContainerCompressionFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RCContainerCompressionFixtureTests.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D42FE5000000000001 /* RCContainer+Compression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RCContainer+Compression.swift"; sourceTree = "<group>"; };
|
||||
A1B2C3D42FE6000000000001 /* RemoteConfigBlobRefHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigBlobRefHelpers.swift; sourceTree = "<group>"; };
|
||||
@@ -7127,6 +7128,7 @@
|
||||
A1B2C3D42FE300000000000 /* BackendIntegrationTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D42FE300000000003 /* APISourcesIntegrationTests.swift */,
|
||||
A1B2C3D42FE300000000001 /* ProductionRemoteConfigIntegrationTests.swift */,
|
||||
A1B2C3D42FE300000000002 /* WorkflowComponentsIntegrationTests.swift */,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//
|
||||
// Copyright RevenueCat Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the MIT License (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://opensource.org/licenses/MIT
|
||||
//
|
||||
// APISourcesIntegrationTests.swift
|
||||
//
|
||||
// Created by Antonio Pallares on 19/10/26.
|
||||
|
||||
import Nimble
|
||||
@testable import RevenueCat
|
||||
import XCTest
|
||||
|
||||
/// The API sources the SDK embeds as defaults, in failover order.
|
||||
private enum APISources {
|
||||
|
||||
static let primary = "https://api.revenuecat.com/"
|
||||
static let backup = "https://api.rc-backup.com/"
|
||||
|
||||
}
|
||||
|
||||
class APISourcesStoreKit1IntegrationTests: APISourcesStoreKit2IntegrationTests {
|
||||
|
||||
override class var storeKitVersion: StoreKitVersion { .storeKit1 }
|
||||
|
||||
}
|
||||
|
||||
/// Runs core SDK flows against the real backend with the `usesRemoteConfigAPISources` dangerous setting
|
||||
/// enabled, so eligible requests resolve their host through the remote config API sources.
|
||||
class APISourcesStoreKit2IntegrationTests: BaseAPISourcesIntegrationTests {
|
||||
|
||||
override class var storeKitVersion: StoreKitVersion { .storeKit2 }
|
||||
|
||||
func testCanGetOfferings() async throws {
|
||||
let offerings = try await self.purchases.offerings()
|
||||
|
||||
expect(offerings.all).toNot(beEmpty())
|
||||
expect(offerings.contents.originalSource) != .fallbackUrl
|
||||
expect(Set(self.apiSourceRecorder.sources(for: HTTPRequest.Path.getOfferings(appUserID: ""))))
|
||||
== [APISources.primary]
|
||||
}
|
||||
|
||||
func testCanPurchaseSubscription() async throws {
|
||||
try await self.purchaseMonthlyOffering()
|
||||
|
||||
expect(Set(self.apiSourceRecorder.sources(for: HTTPRequest.Path.postReceiptData))) == [APISources.primary]
|
||||
}
|
||||
|
||||
func testCanLogIn() async throws {
|
||||
let (customerInfo, created) = try await self.purchases.logIn(UUID().uuidString)
|
||||
|
||||
expect(created) == true
|
||||
expect(customerInfo.entitlements.verification) == .verified
|
||||
expect(self.apiSourceRecorder.sources(for: HTTPRequest.Path.logIn)) == [APISources.primary]
|
||||
}
|
||||
|
||||
func testFetchesRemoteConfigFromThePrimarySource() async throws {
|
||||
try await self.waitForRemoteConfigRequest(toTarget: APISources.primary)
|
||||
|
||||
expect(Set(self.apiSourceRecorder.sources(for: Self.remoteConfigPath))) == [APISources.primary]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class APISourceOutageStoreKit1IntegrationTests: APISourceOutageStoreKit2IntegrationTests {
|
||||
|
||||
override class var storeKitVersion: StoreKitVersion { .storeKit1 }
|
||||
|
||||
}
|
||||
|
||||
/// Runs core SDK flows against the real backend while the primary API source is down, so the SDK fails over
|
||||
/// to the backup source.
|
||||
class APISourceOutageStoreKit2IntegrationTests: BaseAPISourcesIntegrationTests {
|
||||
|
||||
override class var storeKitVersion: StoreKitVersion { .storeKit2 }
|
||||
|
||||
override func createForceServerErrorStrategy() -> ForceServerErrorStrategy {
|
||||
return self.apiSourceRecorder.strategy(simulatingAnOutageOf: [APISources.primary])
|
||||
}
|
||||
|
||||
func testCanGetOfferingsFromTheBackupSource() async throws {
|
||||
let offerings = try await self.purchases.offerings()
|
||||
|
||||
expect(offerings.all).toNot(beEmpty())
|
||||
expect(offerings.contents.originalSource) != .fallbackUrl
|
||||
expect(self.apiSourceRecorder.sources(for: HTTPRequest.Path.getOfferings(appUserID: "")).last)
|
||||
== APISources.backup
|
||||
}
|
||||
|
||||
func testCanGetCustomerInfoFromTheBackupSource() async throws {
|
||||
let customerInfo = try await self.purchases.customerInfo(fetchPolicy: .fetchCurrent)
|
||||
|
||||
expect(customerInfo.entitlements.verification) == .verified
|
||||
expect(self.apiSourceRecorder.sources(for: HTTPRequest.Path.getCustomerInfo(appUserID: "")).last)
|
||||
== APISources.backup
|
||||
}
|
||||
|
||||
func testCanPurchaseSubscriptionThroughTheBackupSource() async throws {
|
||||
let purchase = try await self.purchaseMonthlyOffering()
|
||||
|
||||
expect(purchase.customerInfo.entitlements.verification) == .verified
|
||||
expect(self.apiSourceRecorder.sources(for: HTTPRequest.Path.postReceiptData).last) == APISources.backup
|
||||
}
|
||||
|
||||
func testCanLogInThroughTheBackupSource() async throws {
|
||||
let (customerInfo, created) = try await self.purchases.logIn(UUID().uuidString)
|
||||
|
||||
expect(created) == true
|
||||
expect(customerInfo.entitlements.verification) == .verified
|
||||
expect(self.apiSourceRecorder.sources(for: HTTPRequest.Path.logIn).last) == APISources.backup
|
||||
}
|
||||
|
||||
func testFetchesRemoteConfigFromTheBackupSource() async throws {
|
||||
try await self.waitForRemoteConfigRequest(toTarget: APISources.backup)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Runs against the real backend while the primary API source returns server errors but its health check
|
||||
/// passes, so the SDK doesn't fail over to the backup source.
|
||||
class APISourceServerErrorIntegrationTests: BaseAPISourcesIntegrationTests {
|
||||
|
||||
override class var storeKitVersion: StoreKitVersion { .storeKit2 }
|
||||
|
||||
override func createForceServerErrorStrategy() -> ForceServerErrorStrategy {
|
||||
return self.apiSourceRecorder.strategy(failingRequestsTo: APISources.primary)
|
||||
}
|
||||
|
||||
func testOfferingsUseTheStaticFallbackWithoutFailingOver() async throws {
|
||||
let offerings = try await self.purchases.offerings()
|
||||
|
||||
expect(offerings.all).toNot(beEmpty())
|
||||
expect(offerings.contents.originalSource) == .fallbackUrl
|
||||
expect(self.apiSourceRecorder.sources).toNot(contain(APISources.backup))
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.network.api_source_healthy_despite_failure(host: APISources.primary)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Runs against the real backend while every API source is down.
|
||||
class APISourcesExhaustedIntegrationTests: BaseAPISourcesIntegrationTests {
|
||||
|
||||
override class var storeKitVersion: StoreKitVersion { .storeKit2 }
|
||||
|
||||
override func createForceServerErrorStrategy() -> ForceServerErrorStrategy {
|
||||
return self.apiSourceRecorder.strategy(simulatingAnOutageOf: [APISources.primary, APISources.backup])
|
||||
}
|
||||
|
||||
func testOfferingsUseTheStaticFallback() async throws {
|
||||
let offerings = try await self.purchases.offerings()
|
||||
|
||||
expect(offerings.all).toNot(beEmpty())
|
||||
expect(offerings.contents.originalSource) == .fallbackUrl
|
||||
expect(self.apiSourceRecorder.sources).to(contain(APISources.primary, APISources.backup))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Configures `Purchases` with API sources enabled and a `ForceServerErrorStrategy` that records the API source
|
||||
/// each request attempt resolved.
|
||||
class BaseAPISourcesIntegrationTests: BaseStoreKitIntegrationTests {
|
||||
|
||||
static let remoteConfigPath = HTTPRequest.Path.remoteConfig(domain: RemoteConfiguration.defaultDomain)
|
||||
|
||||
let apiSourceRecorder = APISourceRecorder()
|
||||
|
||||
override var usesRemoteConfigAPISources: Bool { return true }
|
||||
|
||||
// A proxy pins every request to itself, bypassing API sources.
|
||||
override var proxyURL: String? { return nil }
|
||||
|
||||
override func setUp() async throws {
|
||||
self.forceServerErrorStrategy = self.createForceServerErrorStrategy()
|
||||
|
||||
try await super.setUp()
|
||||
}
|
||||
|
||||
/// Subclasses override this to simulate failing API sources.
|
||||
func createForceServerErrorStrategy() -> ForceServerErrorStrategy {
|
||||
return self.apiSourceRecorder.recordingStrategy()
|
||||
}
|
||||
|
||||
/// Remote config is fetched in the background on launch, so this waits for its last attempt to target `source`.
|
||||
func waitForRemoteConfigRequest(toTarget source: String) async throws {
|
||||
let recorder = self.apiSourceRecorder
|
||||
let path = Self.remoteConfigPath
|
||||
try await asyncWait(description: "Remote config was never requested from \(source)") {
|
||||
recorder.sources(for: path).last == source
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Records the API source each request attempt resolved (`nil` when it used its default host), and can fail the
|
||||
/// attempts targeting some hosts with a real 5xx. Source health checks don't go through `HTTPClient`, so they're
|
||||
/// never recorded.
|
||||
final class APISourceRecorder: Sendable {
|
||||
|
||||
private let attempts: Atomic<[(pathName: String, source: String?)]> = .init([])
|
||||
|
||||
var sources: [String?] {
|
||||
return self.attempts.value.map(\.source)
|
||||
}
|
||||
|
||||
func sources(for path: HTTPRequestPath) -> [String?] {
|
||||
return self.attempts.value.filter { $0.pathName == path.name }.map(\.source)
|
||||
}
|
||||
|
||||
/// Performs every request and health check.
|
||||
func recordingStrategy() -> ForceServerErrorStrategy {
|
||||
return self.strategy(failingHosts: [], healthCheckAction: { _ in .performHealthCheck })
|
||||
}
|
||||
|
||||
/// Fails the requests to `source` while its health checks still run, so the SDK sees a healthy source.
|
||||
func strategy(failingRequestsTo source: String) -> ForceServerErrorStrategy {
|
||||
return self.strategy(failingHosts: Self.hosts(of: [source]), healthCheckAction: { _ in .performHealthCheck })
|
||||
}
|
||||
|
||||
/// Fails the requests to `sources` and reports them unhealthy, as if they were down.
|
||||
func strategy(simulatingAnOutageOf sources: Set<String>) -> ForceServerErrorStrategy {
|
||||
let downHosts = Self.hosts(of: sources)
|
||||
return self.strategy(failingHosts: downHosts, healthCheckAction: { url in
|
||||
downHosts.contains(url.host ?? "") ? .fakeUnhealthy : .performHealthCheck
|
||||
})
|
||||
}
|
||||
|
||||
private func strategy(
|
||||
failingHosts: Set<String>,
|
||||
healthCheckAction: @escaping (URL) -> ForceServerErrorStrategy.HealthCheckAction
|
||||
) -> ForceServerErrorStrategy {
|
||||
return .init(action: { [attempts = self.attempts] request in
|
||||
let source = request.apiSourceState.source
|
||||
attempts.modify { $0.append((pathName: request.httpRequest.path.name, source: source?.handle.url)) }
|
||||
|
||||
// Requests without a source, e.g. once every source is exhausted, go to the default host.
|
||||
let host = source?.url.host ?? (request.targetsFallbackHost ? nil : SystemInfo.apiBaseURL.host)
|
||||
return failingHosts.contains(host ?? "") ? .defaultServerError : .performRequest
|
||||
}, healthCheckAction: healthCheckAction)
|
||||
}
|
||||
|
||||
private static func hosts(of sources: Set<String>) -> Set<String> {
|
||||
return Set(sources.compactMap { URL(string: $0)?.host })
|
||||
}
|
||||
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class BaseBackendIntegrationTests: TestCase {
|
||||
return .enforced(Signing.loadPublicKey())
|
||||
}
|
||||
var enableReceiptFetchRetry: Bool = true
|
||||
var usesRemoteConfigAPISources: Bool { return false }
|
||||
|
||||
var apiKey: String { return Constants.apiKey }
|
||||
var proxyURL: String? { return Constants.proxyURL }
|
||||
@@ -294,7 +295,6 @@ extension BaseBackendIntegrationTests: InternalDangerousSettingsType {
|
||||
var forceSignatureFailures: Bool { return false }
|
||||
var disableHeaderSignatureVerification: Bool { return false }
|
||||
var testReceiptIdentifier: String? { return self.testUUID.uuidString }
|
||||
var usesRemoteConfigAPISources: Bool { return false }
|
||||
|
||||
final func serverDown() {
|
||||
self.forceServerErrorStrategy = .allServersDown
|
||||
|
||||
@@ -107,6 +107,20 @@ final class BackendAPISourceFailoverIntegrationTests: TestCase {
|
||||
expect(sourceB.value) == [Self.customerInfoPath]
|
||||
}
|
||||
|
||||
func testGetCustomerInfoFailsOverOnABadURLErrorWhenTheSourceIsUnreachable() {
|
||||
let sourceA = self.stubSource(host: Self.sourceAHost,
|
||||
endpoint: { HTTPStubsResponse(error: URLError(.badURL)) },
|
||||
health: Self.connectionRefused)
|
||||
let sourceB = self.stubSource(host: Self.sourceBHost, endpoint: Self.customerInfoResponse)
|
||||
let backend = Self.createBackend(sources: [Self.sourceAHost, Self.sourceBHost])
|
||||
|
||||
let result = self.fetchCustomerInfo(backend)
|
||||
|
||||
expect(result).to(beSuccess())
|
||||
expect(sourceA.value) == [Self.customerInfoPath, Self.healthPath]
|
||||
expect(sourceB.value) == [Self.customerInfoPath]
|
||||
}
|
||||
|
||||
func testGetCustomerInfoSurfacesAConnectionErrorWithoutFailingOverWhenTheSourceIsHealthy() {
|
||||
let sourceA = self.stubSource(host: Self.sourceAHost,
|
||||
endpoint: Self.connectionRefused,
|
||||
|
||||
@@ -393,6 +393,15 @@ class NetworkErrorTests: TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testBadURLErrorIsAllowedToRetryWithFallbackHost() {
|
||||
// `-1000` never reaches the host, but it isn't caused by the device lacking connectivity either,
|
||||
// so another host may still succeed.
|
||||
let error = Self.urlError(NSURLErrorBadURL)
|
||||
|
||||
expect(error.isDeviceConnectivityError) == false
|
||||
expect(error.isAllowedToRetryWithFallbackHost) == true
|
||||
}
|
||||
|
||||
func testIsDeviceConnectivityErrorFalseForNonNetworkErrors() {
|
||||
let errors = [
|
||||
error(NetworkError.decodingError()),
|
||||
|
||||
Reference in new issue
Block a user