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)