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..f6bd52f 100644
--- a/OptableSDK.xcodeproj/xcshareddata/xcschemes/OptableSDKTests.xcscheme
+++ b/OptableSDK.xcodeproj/xcshareddata/xcschemes/OptableSDKTests.xcscheme
@@ -13,8 +13,9 @@
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
-
-
+
+
@@ -22,8 +23,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 c8e5ba7..1b40b5a 100644
--- a/Source/OptableSDK.swift
+++ b/Source/OptableSDK.swift
@@ -52,18 +52,11 @@ public class OptableSDK: NSObject {
let config: OptableConfig
let api: EdgeAPI
- /// Initializes the SDK with the provided OptableConfig. On iOS 14+, requests tracking authorization unless skipAdvertisingIdDetection is true.
+ /// Initializes the SDK with the provided OptableConfig.
@objc
public init(config: OptableConfig) {
self.config = config
self.api = EdgeAPI(config)
-
- // Automatically request Tracking Authorization
- if #available(iOS 14, *) {
- if config.skipAdvertisingIdDetection == false, ATT.canAuthorize {
- ATT.requestATTAuthorization()
- }
- }
}
/// OptableSDK version
@@ -98,7 +91,7 @@ public extension OptableSDK {
}
```
*/
- func identify(_ ids: [OptableIdentifier], _ completion: @escaping (Result) -> Void) throws {
+ func identify(_ ids: [OptableIdentifier], completion: @escaping (Result) -> Void) throws {
try _identify(ids, completion: completion)
}
@@ -327,8 +320,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
@@ -360,7 +353,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")
}
@@ -448,19 +441,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 c08b13e..ea6c494 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, *)
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..704f3a9
--- /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)
+ XCTAssert(targeting.targetingData.isEmpty)
+ 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)