diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj
index 917d1f8..a05613f 100644
--- a/OptableSDK.xcodeproj/project.pbxproj
+++ b/OptableSDK.xcodeproj/project.pbxproj
@@ -47,6 +47,8 @@
Unit/LocalStorageTests.swift,
Unit/OptableIdentifierEncoderTests.swift,
Unit/OptableIdentifiersTests.swift,
+ Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift,
+ Unit/OptableSDKHelpersTests.swift,
);
target = 6352AB0324EAD403002E66EB /* OptableSDKTests */;
};
@@ -382,6 +384,7 @@
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
diff --git a/OptableSDK.xcodeproj/xcshareddata/xcschemes/OptableSDKTests.xcscheme b/OptableSDK.xcodeproj/xcshareddata/xcschemes/OptableSDKTests.xcscheme
index a093cbf..0c24eb9 100644
--- a/OptableSDK.xcodeproj/xcshareddata/xcschemes/OptableSDKTests.xcscheme
+++ b/OptableSDK.xcodeproj/xcshareddata/xcschemes/OptableSDKTests.xcscheme
@@ -22,8 +22,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES"
- shouldAutocreateTestPlan = "YES">
+ shouldUseLaunchSchemeArgsEnv = "YES">
+
+
+
+
diff --git a/Source/Misc/AppTrackingTransparency.swift b/Source/Misc/AppTrackingTransparency.swift
index b2115f3..5139c9d 100644
--- a/Source/Misc/AppTrackingTransparency.swift
+++ b/Source/Misc/AppTrackingTransparency.swift
@@ -16,16 +16,47 @@
import Foundation
enum ATT {
- static var advertisingIdentifier: UUID {
- ASIdentifierManager.shared().advertisingIdentifier
- }
+ // MARK: advertisingIdentifier
- @available(iOS, introduced: 6, deprecated: 14, message: "This has been replaced by functionality in AppTrackingTransparency's ATTrackingManager class.")
- static var isAdvertisingTrackingEnabled: Bool {
- ASIdentifierManager.shared().isAdvertisingTrackingEnabled
- }
+ #if DEBUG
+ static var advertisingIdentifier_DebugOverride: UUID?
+ static var advertisingIdentifier: UUID {
+ advertisingIdentifier_DebugOverride ?? ASIdentifierManager.shared().advertisingIdentifier
+ }
+ #else
+ static var advertisingIdentifier: UUID {
+ ASIdentifierManager.shared().advertisingIdentifier
+ }
+ #endif
+
+ // MARK: isAdvertisingTrackingEnabled
+
+ #if DEBUG
+ @available(iOS, introduced: 6, deprecated: 14,
+ message: "Replaced by ATTrackingManager in AppTrackingTransparency.")
+ static var isAdvertisingTrackingEnabled_DebugOverride: Bool?
+ static var isAdvertisingTrackingEnabled: Bool {
+ isAdvertisingTrackingEnabled_DebugOverride ?? ASIdentifierManager.shared().isAdvertisingTrackingEnabled
+ }
+ #else
+ static var isAdvertisingTrackingEnabled: Bool {
+ ASIdentifierManager.shared().isAdvertisingTrackingEnabled
+ }
+ #endif
+
+ // MARK: advertisingIdentifierAvailable
+
+ #if DEBUG
+ static var advertisingIdentifierAvailable_DebugOverride: Bool?
+ #endif
static var advertisingIdentifierAvailable: Bool {
+ #if DEBUG
+ if let override = advertisingIdentifierAvailable_DebugOverride {
+ return override
+ }
+ #endif
+
#if canImport(AppTrackingTransparency)
if #available(iOS 14, *) {
return trackingStatus == .authorized
@@ -36,8 +67,20 @@
return isAdvertisingTrackingEnabled
#endif
}
-
+
+ // MARK: attAvailable
+
+ #if DEBUG
+ static var attAvailable_DebugOverride: Bool?
+ #endif
+
static var attAvailable: Bool {
+ #if DEBUG
+ if let override = attAvailable_DebugOverride {
+ return override
+ }
+ #endif
+
if #available(iOS 14, *) {
return true
} else {
@@ -47,26 +90,62 @@
#if canImport(AppTrackingTransparency)
+ // MARK: canAuthorize
+
+ #if DEBUG
+ @available(iOS 14, *)
+ static var canAuthorize_DebugOverride: Bool?
+ #endif
+
static var canAuthorize: Bool {
if #available(iOS 14, *) {
+ #if DEBUG
+ if let override = canAuthorize_DebugOverride {
+ return override
+ }
+ #endif
+
return ATTrackingManager.trackingAuthorizationStatus == .notDetermined
} else {
return false
}
}
+ // MARK: trackingStatus
+
+ #if DEBUG
+ @available(iOS 14, *)
+ static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus?
+ #endif
+
@available(iOS 14, *)
static var trackingStatus: ATTrackingManager.AuthorizationStatus {
- ATTrackingManager.trackingAuthorizationStatus
+ #if DEBUG
+ return trackingStatus_DebugOverride ?? ATTrackingManager.trackingAuthorizationStatus
+ #else
+ return ATTrackingManager.trackingAuthorizationStatus
+ #endif
}
+ // MARK: RequestAuthorization
+
@available(iOS 14, *)
static func requestATTAuthorization(completion: ((Bool) -> Void)? = nil) {
+ #if DEBUG
+ if let override = trackingStatus_DebugOverride {
+ completion?(override == .authorized)
+ return
+ }
+ #endif
+
ATTrackingManager.requestTrackingAuthorization { status in
switch status {
- case .authorized: completion?(true)
- case .denied, .notDetermined, .restricted: completion?(false)
- @unknown default: completion?(true)
+ case .authorized:
+ completion?(true)
+ case .denied, .notDetermined, .restricted:
+ completion?(false)
+ @unknown default:
+ completion?(true)
}
}
}
@@ -74,11 +153,11 @@
@available(iOS 14, *)
@discardableResult
static func requestATTAuthorization() async -> Bool {
- await withCheckedContinuation({ continuation in
- requestATTAuthorization(completion: { isAuthorized in
+ await withCheckedContinuation { continuation in
+ requestATTAuthorization { isAuthorized in
continuation.resume(returning: isAuthorized)
- })
- })
+ }
+ }
}
#endif
diff --git a/Source/Misc/RangeReplaceableCollection+Compat.swift b/Source/Misc/RangeReplaceableCollection+Compat.swift
new file mode 100644
index 0000000..329a242
--- /dev/null
+++ b/Source/Misc/RangeReplaceableCollection+Compat.swift
@@ -0,0 +1,22 @@
+//
+// RangeReplaceableCollection+Compat.swift
+// OptableSDK
+//
+// Copyright © 2026 Optable Technologies, Inc. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+extension RangeReplaceableCollection where Self: MutableCollection, Index == Int {
+ mutating func removeCompat(atOffsets offsets: IndexSet) {
+ if #available(iOS 13.0, *) {
+ remove(atOffsets: offsets)
+ } else {
+ // Remove from highest index to lowest to avoid shifting issues
+ for index in offsets.sorted(by: >) {
+ remove(at: index)
+ }
+ }
+ }
+}
diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift
index 8588677..359f9b9 100644
--- a/Source/OptableSDK.swift
+++ b/Source/OptableSDK.swift
@@ -85,12 +85,13 @@ public extension OptableSDK {
It is asynchronous, and on completion it will call the specified completion handler, passing
it either the HTTPURLResponse on success, or an NSError on failure.
+
```swift
// Example
- optableSDK.identify(.init(emailAddress: "example@example.com", phoneNumber: "1234567890"), completion)
+ optableSDK.identify([.emailAddress("example@example.com"), .phoneNumber("1234567890")], completion: completion)
```
*/
- func identify(_ ids: [OptableIdentifier], _ completion: @escaping (Result) -> Void) throws {
+ func identify(_ ids: [OptableIdentifier], completion: @escaping (Result) -> Void) throws {
try _identify(ids, completion: completion)
}
@@ -141,6 +142,11 @@ public extension OptableSDK {
On success, this method will also cache the resulting targeting data in client storage, which can
be access using targetingFromCache(), and cleared using targetingClearCache().
+
+ ```swift
+ // Example
+ optableSDK.targeting([.emailAddress("example@example.com"), .phoneNumber("1234567890")], completion: completion)
+ ```
*/
func targeting(_ ids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws {
try _targeting(ids: ids, completion: completion)
@@ -203,7 +209,7 @@ public extension OptableSDK {
The witness method is asynchronous, and on completion it will call the specified completion handler,
passing it either the HTTPURLResponse on success, or an NSError on failure.
*/
- func witness(event: String, properties: NSDictionary, _ completion: @escaping (Result) -> Void) throws {
+ func witness(event: String, properties: NSDictionary, completion: @escaping (Result) -> Void) throws {
try _witness(event: event, properties: properties, completion: completion)
}
@@ -251,7 +257,7 @@ public extension OptableSDK {
The specified NSDictionary 'traits' can be subsequently used for audience assembly.
The profile method is asynchronous, and on completion it will call the specified completion handler, passing it either the HTTPURLResponse on success, or an NSError on failure.
*/
- func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping (Result) -> Void) throws {
+ func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil, completion: @escaping (Result) -> Void) throws {
try _profile(traits: traits, id: id, neighbors: neighbors, completion: completion)
}
@@ -312,8 +318,8 @@ public extension OptableSDK {
}
}
-// MARK: - Private
-private extension OptableSDK {
+// MARK: - Internal
+extension OptableSDK {
func _identify(_ ids: [OptableIdentifier], completion: @escaping (Result) -> Void) throws {
var ids = ids
@@ -345,7 +351,7 @@ private extension OptableSDK {
var ids = ids ?? []
enrichIfNeeded(ids: &ids)
-
+
guard let request = try api.targeting(ids: ids) else {
throw OptableError.targeting("Failed to create targeting request")
}
@@ -433,19 +439,29 @@ private extension OptableSDK {
}
}).resume()
}
-
- private func enrichIfNeeded(ids: inout [OptableIdentifier]) {
+
+ func enrichIfNeeded(ids: inout [OptableIdentifier]) {
// Enrich with Apple IDFA
if config.skipAdvertisingIdDetection == false,
ATT.advertisingIdentifierAvailable,
- ATT.advertisingIdentifier != UUID(uuid: uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)),
- ids.contains(where: { eid in
- if case let .appleIDFA(value) = eid, value.isEmpty == false {
- return true
- }
- return false
- }) == false {
- ids.append(.appleIDFA(ATT.advertisingIdentifier.uuidString))
+ ATT.advertisingIdentifier != UUID(uuid: uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) {
+ let systemIDFA = ATT.advertisingIdentifier.uuidString
+
+ var idfaMatchingSystemIdxs: [Int] = []
+
+ for idx in ids.indices {
+ if case let .appleIDFA(value) = ids[idx] {
+ if value == systemIDFA {
+ idfaMatchingSystemIdxs.append(idx)
+ }
+ }
+ }
+
+ // Remove all matching systemIDFA (deduplicate)
+ ids.removeCompat(atOffsets: IndexSet(idfaMatchingSystemIdxs))
+
+ // Prepend all identifiers with systemIDFA
+ ids.insert(.appleIDFA(systemIDFA), at: ids.startIndex)
}
}
diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.h b/Source/Public/ObjCSupport/OptableSDKIdentifier.h
index 1967926..4077165 100644
--- a/Source/Public/ObjCSupport/OptableSDKIdentifier.h
+++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.h
@@ -10,8 +10,6 @@
#import
-//#import
-
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, OptableSDKIdentifierType) {
diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift
index d4bd350..fc71dfc 100644
--- a/Tests/Integration/OptableSDKTests.swift
+++ b/Tests/Integration/OptableSDKTests.swift
@@ -76,6 +76,30 @@ class OptableSDKTests: XCTestCase {
try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)])
wait(for: [targetExpectation], timeout: 10)
}
+
+ func test_targetingFromCache_and_targetingClearCache() {
+ let config = OptableConfig(tenant: T.api.tenant.prebidtest, originSlug: T.api.slug.iosSDK)
+ let sdk = OptableSDK(config: config)
+
+ // Seed storage directly
+ let expected = OptableTargeting(
+ optableTargeting: ["foo": "bar"],
+ gamTargetingKeywords: ["ks": "id1,id2"],
+ ortb2: "{\"user\":{}}"
+ )
+ sdk.api.storage.setTargeting(expected)
+
+ // Read through SDK wrapper
+ let fromCache = sdk.targetingFromCache()
+ XCTAssertNotNil(fromCache)
+ XCTAssertEqual(fromCache!.targetingData as? [String: String], ["foo": "bar"])
+ XCTAssertEqual(fromCache!.gamTargetingKeywords as? [String: String], ["ks": "id1,id2"])
+ XCTAssertEqual(fromCache!.ortb2, "{\"user\":{}}")
+
+ // Clear and verify empty
+ sdk.targetingClearCache()
+ XCTAssertNil(sdk.targetingFromCache())
+ }
// MARK: Witness
@available(iOS 13.0, *)
@@ -87,7 +111,7 @@ class OptableSDKTests: XCTestCase {
func test_witness_callbacks() throws {
let expectation = expectation(description: "witness-callback-expectation")
- try sdk.witness(event: "test", properties: ["integration-test-witness": "integration-test-witness-value"], { result in
+ try sdk.witness(event: "test", properties: ["integration-test-witness": "integration-test-witness-value"], completion: { result in
switch result {
case let .success(response):
XCTAssert(response.allHeaderFields.keys.contains("x-optable-visitor"))
@@ -114,7 +138,7 @@ class OptableSDKTests: XCTestCase {
func test_profile_callbacks() throws {
let expectation = expectation(description: "profile-callback-expectation")
- try sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"], { result in
+ try sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"], completion: { result in
switch result {
case let .success(response):
XCTAssert(response.targetingData.allKeys.isEmpty == false)
diff --git a/Tests/OptableSDKTests.xctestplan b/Tests/OptableSDKTests.xctestplan
new file mode 100644
index 0000000..ba969e0
--- /dev/null
+++ b/Tests/OptableSDKTests.xctestplan
@@ -0,0 +1,24 @@
+{
+ "configurations" : [
+ {
+ "id" : "CA98C897-F222-4E1B-9D66-8D550B33E18E",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "performanceAntipatternCheckerEnabled" : true
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:OptableSDK.xcodeproj",
+ "identifier" : "6352AB0324EAD403002E66EB",
+ "name" : "OptableSDKTests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift
index 9cedd57..0160427 100644
--- a/Tests/Unit/OptableIdentifiersTests.swift
+++ b/Tests/Unit/OptableIdentifiersTests.swift
@@ -29,7 +29,7 @@ class OptableIdentifiersTests: XCTestCase {
.custom(1, "AaaZza.dh012"),
.custom(1, "another c1"),
]
-
+
let encodedData = try JSONEncoder().encode(oids)
let decodedData = try JSONDecoder().decode([String].self, from: encodedData)
@@ -55,9 +55,10 @@ class OptableIdentifiersTests: XCTestCase {
XCTAssertTrue(decodedData.contains(where: { $0 == "c1:another c1" }))
// Test order
- let c_Idx = decodedData.firstIndex(of: "c:d29c551097b9dd0b82423827f65161232efaf7fc")!
- let c1_Idx = decodedData.firstIndex(of: "c1:AaaZza.dh012")!
- let c2_Idx = decodedData.firstIndex(of: "c2:")!
+
+ let c_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c:d29c551097b9dd0b82423827f65161232efaf7fc"))
+ let c1_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c1:AaaZza.dh012"))
+ let c2_Idx = try XCTUnwrap(decodedData.firstIndex(of: "c2:"))
XCTAssert(c_Idx < c2_Idx)
XCTAssert(c2_Idx < c1_Idx)
diff --git a/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift b/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift
new file mode 100644
index 0000000..085cb4a
--- /dev/null
+++ b/Tests/Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift
@@ -0,0 +1,203 @@
+//
+// OptableIdentifiersEnrichTests.swift
+// OptableSDK
+//
+// Copyright © 2026 Optable Technologies, Inc. All rights reserved.
+//
+
+import XCTest
+
+@testable import OptableSDK
+
+private let systemIDFA: String = "9A8C574D-0B13-45B3-AC67-7CA9C8851920"
+private let userIDFA: String = "7F51D71F-3D94-436D-B3FE-CEF646011359"
+private let userIDFA_2: String = "543769CE-8339-4502-8D1F-4764008C5C37"
+
+// MARK: - OptableSDKHelpersIdentifiersEnrichmentTests
+class OptableSDKHelpersIdentifiersEnrichmentTests: XCTestCase {
+ func test_idfa_detection_disabled_enrich_user_idfa_no_prepend() {
+ var identifiers = buildIdentifiers()
+ identifiers.append(.appleIDFA(userIDFA))
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = true
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if case .appleIDFA(_) = identifiers[0] {
+ XCTFail("User provided IDFA should not be prepended. (System IDFA is unavailable)")
+ }
+ }
+
+ func test_idfa_detection_disabled_enrich_user_idfas_no_prepend() {
+ var identifiers = buildIdentifiers()
+ identifiers.append(.appleIDFA(userIDFA))
+ identifiers.append(.appleIDFA(userIDFA_2))
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = true
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if case .appleIDFA(_) = identifiers[0] {
+ XCTFail("User IDFA should not be prepended. (System IDFA is unavailable)")
+ }
+
+ if case .appleIDFA(_) = identifiers[1] {
+ XCTFail("User IDFA should not be prepended. (System IDFA is unavailable)")
+ }
+ }
+
+ func test_idfa_detection_enabled_enrich_system_idfa_prepend() {
+ ATT.advertisingIdentifierAvailable_DebugOverride = true
+ ATT.advertisingIdentifier_DebugOverride = UUID(uuidString: systemIDFA)
+
+ var identifiers = buildIdentifiers()
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = false
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if case let .appleIDFA(value) = identifiers[0] {
+ XCTAssert(value == systemIDFA, "Prepended IDFA is not the same as system provided")
+ } else {
+ XCTFail("System IDFA is not prepended")
+ }
+ }
+
+ @available(iOS 14, *)
+ func test_idfa_detection_enabled_enrich_system_idfa_same_as_user_idfa_prepend() {
+ ATT.advertisingIdentifierAvailable_DebugOverride = true
+ ATT.advertisingIdentifier_DebugOverride = UUID(uuidString: systemIDFA)
+
+ var identifiers = buildIdentifiers()
+ identifiers.append(.appleIDFA(systemIDFA))
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = false
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if case let .appleIDFA(value) = identifiers[0] {
+ XCTAssert(value == systemIDFA, "Prepended IDFA is not the same as system provided")
+ } else {
+ XCTFail("System IDFA is not prepended")
+ }
+
+ if case .appleIDFA = identifiers[1] {
+ XCTFail("User IDFA persists and was prepended. (duplicate)")
+ }
+
+ if case .appleIDFA = identifiers.last {
+ XCTFail("User IDFA persists. (duplicate)")
+ }
+
+ if identifiers.count(where: { if case .appleIDFA = $0 { return true } else { return false } }) > 1 {
+ XCTFail("User IDFA persists. (duplicate)")
+ }
+ }
+
+ @available(iOS 14, *)
+ func test_idfa_detection_enabled_enrich_system_idfa_user_idfa_persist() {
+ ATT.advertisingIdentifierAvailable_DebugOverride = true
+ ATT.advertisingIdentifier_DebugOverride = UUID(uuidString: systemIDFA)
+
+ var identifiers = buildIdentifiers()
+ identifiers.append(.appleIDFA(userIDFA))
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = false
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if case let .appleIDFA(value) = identifiers[0] {
+ XCTAssert(value == systemIDFA, "Prepended IDFA is not the same as system provided")
+ } else {
+ XCTFail("System IDFA is not prepended")
+ }
+
+ if identifiers.contains(where: {
+ if case let .appleIDFA(value) = $0 { return value == userIDFA } else { return false }
+ }) {
+ if case let .appleIDFA(value) = identifiers.last {
+ XCTAssert(value == userIDFA, "Persisted User IDFA is not the same as user provided")
+ } else {
+ XCTFail("Persisted User IDFA is not on the correct position. (should be last)")
+ }
+ } else {
+ XCTFail("User IDFA not persists")
+ }
+ }
+
+ func test_idfa_detection_enabled_enrich_system_idfa_user_idfas_persist() {
+ ATT.advertisingIdentifierAvailable_DebugOverride = true
+ ATT.advertisingIdentifier_DebugOverride = UUID(uuidString: systemIDFA)
+
+ var identifiers = buildIdentifiers()
+ identifiers.append(.appleIDFA(userIDFA))
+ identifiers.append(.appleIDFA(userIDFA_2))
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = false
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if case let .appleIDFA(value) = identifiers[0] {
+ XCTAssert(value == systemIDFA, "Prepended IDFA is not the same as system provided")
+ } else {
+ XCTFail("System IDFA is not prepended")
+ }
+
+ let filteredIdentifiers = identifiers.filter({
+ if case let .appleIDFA(value) = $0 {
+ return value == userIDFA || value == userIDFA_2
+ } else { return false }
+ })
+
+ if filteredIdentifiers.count == 2 {
+ if case let .appleIDFA(value1) = identifiers[identifiers.count - 2],
+ case let .appleIDFA(value2) = identifiers[identifiers.count - 1] {
+ XCTAssert(value1 == userIDFA && value2 == userIDFA_2, "Persisted IDFAs are not the same as User IDFAs or in wrong order")
+ } else {
+ XCTFail("Persisted IDFAs are not the same as User IDFAs or in wrong order")
+ }
+ } else {
+ XCTFail("User IDFAs are not persisted")
+ }
+ }
+
+ func test_idfa_detection_enabled_enrich_system_idfa_zero_uuid_no_prepend() {
+ ATT.advertisingIdentifierAvailable_DebugOverride = true
+ ATT.advertisingIdentifier_DebugOverride = UUID(uuid: uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+
+ var identifiers: [OptableIdentifier] = [
+ .emailAddress("test@test.com"),
+ .phoneNumber("1234567890"),
+ ]
+
+ let sdk = buildSDK()
+ sdk.config.skipAdvertisingIdDetection = false
+ sdk.enrichIfNeeded(ids: &identifiers)
+
+ if identifiers.contains(where: { if case .appleIDFA = $0 { return true } else { return false } }) {
+ XCTFail("Zero System IDFA should not be prepended or persisted")
+ }
+ }
+
+ // MARK: Builders
+
+ func buildSDK() -> OptableSDK {
+ return OptableSDK(config: OptableConfig(
+ tenant: T.api.tenant.prebidtest,
+ originSlug: T.api.slug.iosSDK,
+ insecure: false,
+ customUserAgent: T.api.userAgent,
+ skipAdvertisingIdDetection: true
+ ))
+ }
+
+ func buildIdentifiers() -> [OptableIdentifier] {
+ [
+ .emailAddress("test@test.com"),
+ .phoneNumber("1234567890"),
+ .postalCode("12345"),
+ .ipv4Address("127.0.0.1"),
+ .ipv6Address("2001:db8::7"),
+ ]
+ }
+}
diff --git a/Tests/Unit/OptableSDKHelpersTests.swift b/Tests/Unit/OptableSDKHelpersTests.swift
new file mode 100644
index 0000000..f187141
--- /dev/null
+++ b/Tests/Unit/OptableSDKHelpersTests.swift
@@ -0,0 +1,151 @@
+//
+// OptableSDKHelpersTests.swift
+// OptableSDK
+//
+// Copyright © 2026 Optable Technologies, Inc. All rights reserved.
+//
+
+@testable import OptableSDK
+import XCTest
+
+class OptableSDKHelpersTests: XCTestCase {
+ // MARK: Identifiers Enrichment
+ // MARK: GAM Keywords
+ func test_generateGAMTargetingKeywords_nilOrEmpty() {
+ XCTAssertNil(OptableSDK.generateGAMTargetingKeywords(from: nil))
+ XCTAssertNil(OptableSDK.generateGAMTargetingKeywords(from: [:]))
+ XCTAssertNil(OptableSDK.generateGAMTargetingKeywords(from: ["user": [:]]))
+ }
+
+ func test_generateGAMTargetingKeywords_valid() {
+ let targetingData: NSDictionary = [
+ "audience": [
+ [
+ "keyspace": "ks1",
+ "ids": [["id": "a1"], ["id": "a2"]],
+ ],
+ [
+ "keyspace": "ks2",
+ "ids": [["id": "b1"]],
+ ],
+ ],
+ ]
+
+ let result = OptableSDK.generateGAMTargetingKeywords(from: targetingData)
+ XCTAssertNotNil(result)
+ XCTAssertEqual(result?["ks1"] as? String, "a1,a2")
+ XCTAssertEqual(result?["ks2"] as? String, "b1")
+ }
+
+ // MARK: ORTB2 Config
+ func test_generateORTB2Config_nilOrEmpty() {
+ XCTAssertNil(OptableSDK.generateORTB2Config(from: nil))
+ XCTAssertNil(OptableSDK.generateORTB2Config(from: [:]))
+ XCTAssertNil(OptableSDK.generateORTB2Config(from: ["user": [:]]))
+ }
+
+ func test_generateORTB2Config_valid() throws {
+ let ortb2: NSDictionary = [
+ "user": [
+ "data": [
+ [
+ "id": "optable.co",
+ "segment": [["id": "seg-1"], ["id": "seg-2"]],
+ ],
+ ],
+ ],
+ ]
+ let targetingData: NSDictionary = [
+ "ortb2": ortb2,
+ ]
+
+ guard let result = OptableSDK.generateORTB2Config(from: targetingData) else {
+ return XCTFail("Expected non-nil ORTB2 config string")
+ }
+
+ // Validate by decoding back to JSON and comparing dictionaries
+ let data = try XCTUnwrap(result.data(using: .utf8))
+ let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary)
+ XCTAssertEqual(json, ortb2)
+ }
+
+ // MARK: OptableTargeting
+ func test_generateOptableTargeting_nilData_returnsEmpty() throws {
+ let targeting = try OptableSDK.generateOptableTargeting(from: nil)
+ XCTAssertEqual(targeting.targetingData, [:])
+ XCTAssertNil(targeting.gamTargetingKeywords)
+ XCTAssertNil(targeting.ortb2)
+ }
+
+ func test_generateOptableTargeting_parsesAudienceAndORTB2() throws {
+ let jsonDict: NSDictionary = [
+ "audience": [
+ [
+ "provider": "optable.co",
+ "keyspace": "ks1",
+ "ids": [["id": "a1"], ["id": "a2"]],
+ ],
+ [
+ "provider": "optable.co",
+ "keyspace": "ks2",
+ "ids": [["id": "b1"]],
+ ],
+ ],
+ "ortb2": [
+ "user": [
+ "data": [
+ [
+ "id": "optable.co",
+ "segment": [["id": "seg-1"]],
+ ],
+ ],
+ ],
+ ],
+ ]
+
+ let data = try JSONSerialization.data(withJSONObject: jsonDict, options: [])
+ let targeting = try OptableSDK.generateOptableTargeting(from: data)
+
+ // targetingData should reflect input JSON
+ XCTAssertEqual(targeting.targetingData["audience"] as? NSArray, jsonDict["audience"] as? NSArray)
+
+ // gamTargetingKeywords should be derived from "audience"
+ XCTAssertEqual(targeting.gamTargetingKeywords?["ks1"] as? String, "a1,a2")
+ XCTAssertEqual(targeting.gamTargetingKeywords?["ks2"] as? String, "b1")
+
+ // ortb2 should be a JSON string equivalent to provided dict
+ let ortb2String = try XCTUnwrap(targeting.ortb2)
+ let ortb2Data = try XCTUnwrap(ortb2String.data(using: .utf8))
+ let ortb2Decoded = try XCTUnwrap(try JSONSerialization.jsonObject(with: ortb2Data, options: []) as? NSDictionary)
+ XCTAssertEqual(ortb2Decoded, jsonDict["ortb2"] as? NSDictionary)
+ }
+
+ // MARK: EdgeAPIErrorDescription
+ func test_generateEdgeAPIErrorDescription_includesStatusAndJSON() throws {
+ let url = try XCTUnwrap(URL(string: "https://example.com"))
+ let response = try XCTUnwrap(HTTPURLResponse(url: url, statusCode: 418, httpVersion: nil, headerFields: nil))
+ let json: NSDictionary = ["error": "I'm a teapot", "code": 418]
+ let data = try? JSONSerialization.data(withJSONObject: json, options: [])
+
+ let message = OptableSDK.generateEdgeAPIErrorDescription(with: data, response: response)
+ XCTAssertTrue(message.contains("HTTP response.statusCode: 418"))
+ XCTAssertTrue(message.contains("error"))
+ XCTAssertTrue(message.contains("teapot"))
+ }
+
+ func test_generateEdgeAPIErrorDescription_noData() throws {
+ let url = try XCTUnwrap(URL(string: "https://example.com"))
+ let response = try XCTUnwrap(HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil))
+
+ let message = OptableSDK.generateEdgeAPIErrorDescription(with: nil, response: response)
+ XCTAssertTrue(message.contains("HTTP response.statusCode: 500"))
+ XCTAssertFalse(message.contains("data:"))
+ }
+
+ // MARK: Version
+ func test_version_notUnknown() {
+ // Should resolve to something like ios--
+ XCTAssertNotEqual(OptableSDK.version, "ios-unknown")
+ XCTAssertTrue(OptableSDK.version.hasPrefix("ios-"))
+ }
+}
diff --git a/demo-ios-objc/demo-ios-objc/AppDelegate.m b/demo-ios-objc/demo-ios-objc/AppDelegate.m
index c3644d8..14297bc 100644
--- a/demo-ios-objc/demo-ios-objc/AppDelegate.m
+++ b/demo-ios-objc/demo-ios-objc/AppDelegate.m
@@ -30,7 +30,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(
OptableSDKDelegate *delegate = [OptableSDKDelegate new];
OptableConfig *config = [[OptableConfig alloc] initWithTenant: @"prebidtest" originSlug: @"ios-sdk"];
- config.host = @"prebidtest.cloud.optable.co";
+ config.host = @"na.cloud.optable.co";
OPTABLE = [[OptableSDK alloc] initWithConfig: config];
OPTABLE.delegate = delegate;
diff --git a/demo-ios-swift/demo-ios-swift/AppDelegate.swift b/demo-ios-swift/demo-ios-swift/AppDelegate.swift
index 116b410..5dbaabc 100644
--- a/demo-ios-swift/demo-ios-swift/AppDelegate.swift
+++ b/demo-ios-swift/demo-ios-swift/AppDelegate.swift
@@ -31,7 +31,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let config = OptableConfig(
tenant: "prebidtest",
originSlug: "ios-sdk",
- host: "prebidtest.cloud.optable.co",
+ host: "ca.edge.optable.co",
skipAdvertisingIdDetection: false
)
OPTABLE = OptableSDK(config: config)