diff --git a/.github/workflows/api-compat.yml b/.github/workflows/api-compat.yml new file mode 100644 index 0000000..8c0639c --- /dev/null +++ b/.github/workflows/api-compat.yml @@ -0,0 +1,80 @@ +name: API Compatibility + +on: [pull_request] + +permissions: {} + +jobs: + check-api-compat: + name: Check for API Breaking Changes + runs-on: macos-latest + permissions: + contents: read + + steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: latest-stable + + - name: Build base branch + run: | + xcodebuild build \ + -scheme MinFraudDevice \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath .build/api-compat-base \ + ONLY_ACTIVE_ARCH=NO \ + | xcpretty && exit ${PIPESTATUS[0]} + + - name: Generate baseline from base branch + run: | + xcrun swift-api-digester -dump-sdk \ + -module MinFraudDevice \ + -sdk "$(xcrun --show-sdk-path --sdk iphonesimulator)" \ + -target arm64-apple-ios15.0-simulator \ + -I .build/api-compat-base/Build/Products/Debug-iphonesimulator \ + -swift-only \ + -o .build/api-baseline.json + + - name: Checkout PR branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + clean: false + + - name: Build PR branch + run: | + xcodebuild build \ + -scheme MinFraudDevice \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath .build/api-compat-pr \ + ONLY_ACTIVE_ARCH=NO \ + | xcpretty && exit ${PIPESTATUS[0]} + + - name: Check API compatibility + run: | + DIAGS=$(xcrun swift-api-digester -diagnose-sdk \ + -module MinFraudDevice \ + -baseline-path .build/api-baseline.json \ + -sdk "$(xcrun --show-sdk-path --sdk iphonesimulator)" \ + -target arm64-apple-ios15.0-simulator \ + -I .build/api-compat-pr/Build/Products/Debug-iphonesimulator \ + -swift-only \ + -compiler-style-diags 2>&1) + + if echo "$DIAGS" | grep -q "API breakage:"; then + echo "::error::API breaking changes detected:" + echo "$DIAGS" | grep "API breakage:" + echo "" + echo "This check compares the public API of this PR against the base branch." + echo "If this change is intentional, no action is needed — this is an informational check." + exit 1 + fi + + echo "No API breaking changes detected." diff --git a/CLAUDE.md b/CLAUDE.md index f22421b..0ef0337 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Package Manager and targets iOS 15+. Follow Swift API Design Guidelines: - All-caps for acronyms: `SDKConfig`, `DeviceAPIClient`, `IDFV`, `URL` -- Use "tracking token" in public API, matching the minFraud API terminology +- Use "tracking token" in public API, "stored ID" internally and on the wire - Time units as suffixes: `collectionIntervalSeconds`, `requestDurationMS` ## Build Commands @@ -82,6 +82,10 @@ xcodebuild build \ Unlike the Android sibling SDK, there is no singleton pattern. Users create instances directly. +Objective-C compatible wrappers (`MMSDKConfig`, `MMDeviceTracker`, +`MMTrackingResult`) live in `ObjC/` and wrap the Swift types with `NSObject` +subclasses and completion-handler APIs. + ### Four-Layer Architecture 1. **Public API Layer** (`DeviceTracker.swift`) @@ -94,18 +98,19 @@ instances directly. - Immutable configuration with precondition validation - Default servers: `d-ipv6.mmapiws.com` and `d-ipv4.mmapiws.com` - - Collection interval: 0 (disabled) or >= 300 seconds + - Collection interval: 0 (disabled) or 300–86400 seconds 3. **Data Collection Layer** (`Collector/DeviceDataCollector.swift`) - Retrieves IDFV from Keychain (cached) or system (`UIDevice`) - - Reads tracking token from Keychain for inclusion in requests + - Reads stored ID from Keychain for inclusion in requests - Throws `MinFraudDeviceError.idfvUnavailable` if IDFV cannot be obtained 4. **Network Layer** (`Network/DeviceAPIClient.swift`) - URLSession-based HTTP client - Dual-stack IPv6/IPv4 flow (see below) - Throws `APIError.serverError` on non-success responses + - Throws `APIError.responseDecodingFailed` on unparseable success responses ### Dual-Request Flow (IPv6/IPv4) @@ -115,13 +120,14 @@ To capture both IP addresses for a device: 2. If response contains `ip_version: 6`, POST to `d-ipv4.mmapiws.com/device/ios` with request duration 3. IPv4 failure is non-fatal (logged, not propagated) -4. Tracking token from IPv6 response is returned and persisted +4. Stored ID from IPv6 response is returned to the caller; `DeviceTracker` + persists it in the keychain and returns it as a tracking token If a custom server URL is configured, dual-request is disabled. ### Storage -`KeychainStorage` persists IDFV and tracking token in the iOS Keychain: +`KeychainStorage` persists IDFV and stored ID in the iOS Keychain: - Service: `com.maxmind.minfraud.device` - Accessibility: `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` @@ -145,5 +151,6 @@ disabled, `logger` is `nil` and all `logger?.method()` calls are no-ops. ## Error Types -- `MinFraudDeviceError` (public) — `idfvUnavailable`, `missingTrackingToken` -- `APIError` (public) — `serverError(statusCode:message:)` +- `MinFraudDeviceError` (public) — `idfvUnavailable` +- `APIError` (public) — `serverError(statusCode:message:)`, + `responseDecodingFailed(String)` diff --git a/Package.swift b/Package.swift index 5390f96..60575bd 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,10 @@ let package = Package( .testTarget( name: "MinFraudDeviceTests", dependencies: ["MinFraudDevice"] + ), + .testTarget( + name: "MinFraudDeviceObjCTests", + dependencies: ["MinFraudDevice"] ) ] ) diff --git a/README.dev.md b/README.dev.md index 61ba713..1a00002 100644 --- a/README.dev.md +++ b/README.dev.md @@ -10,8 +10,8 @@ git checkout -b release/X.Y.Z ``` -2. Update the version string in the User-Agent header in - `Sources/MinFraudDevice/Network/DeviceAPIClient.swift`. +2. Update the version constant in + `Sources/MinFraudDevice/Config/SDKConfig.swift`. 3. Update the version in the README.md installation example if needed. 4. Update `CHANGELOG.md`: set the release date and document any final changes. 5. Verify the privacy manifest is up to date diff --git a/README.md b/README.md index 0b0eeac..c3155ea 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ iOS SDK for collecting and reporting device data to MaxMind. - iOS 15.0+ - Swift 5.9+ -- Xcode 14.3+ +- Xcode 15.0+ ## Installation @@ -35,7 +35,7 @@ let tracker = DeviceTracker(config: config) ### 2. Collect and Send Device Data -And, use the tracking token in your calls to the minFraud API. +Use the tracking token in your calls to the minFraud API. ```swift do { @@ -57,20 +57,20 @@ let config = SDKConfig( accountID: 123456, // Your MaxMind account ID serverURL: nil, // nil = default dual-stack servers loggingEnabled: false, // enable logging via os.Logger - collectionIntervalSeconds: 0 // 0 = disabled; >= 300 = automatic collection interval in seconds + collectionIntervalSeconds: 0 // 0 = disabled; 300–86400 = automatic collection interval in seconds ) ``` -| Parameter | Type | Default | Description | -| --------------------------- | ---- | ---------- | ---------------------------------------------------------- | -| `accountID` | Int | _required_ | Your MaxMind account ID | -| `serverURL` | URL? | `nil` | Custom server URL (`nil` = default dual-stack servers) | -| `loggingEnabled` | Bool | `false` | Enable logging via `os.Logger` | -| `collectionIntervalSeconds` | Int | `0` | Auto-collection interval in seconds (0 = disabled, >= 300) | +| Parameter | Type | Default | Description | +| --------------------------- | ---- | ---------- | ------------------------------------------------------------- | +| `accountID` | Int | _required_ | Your MaxMind account ID | +| `serverURL` | URL? | `nil` | Custom server URL (`nil` = default dual-stack servers) | +| `loggingEnabled` | Bool | `false` | Enable logging via `os.Logger` | +| `collectionIntervalSeconds` | Int | `0` | Auto-collection interval in seconds (0 = disabled, 300–86400) | ### Automatic Collection -When `collectionIntervalSeconds` is set to a value of 300 or greater, the +When `collectionIntervalSeconds` is set to a value between 300 and 86400, the tracker automatically collects and sends device data at the specified interval: ```swift @@ -89,6 +89,32 @@ Call `shutdown()` to cancel automatic collection and release resources: tracker.shutdown() ``` +### Objective-C + +The SDK provides Objective-C compatible wrapper classes with an `MM` prefix. + +```objc +@import MinFraudDevice; + +MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:123456]; +if (!config) { + // Handle invalid configuration + return; +} +MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; + +[tracker collectAndSendWithCompletion:^(MMTrackingResult *result, NSError *error) { + if (error) { + NSLog(@"Failed to send device data: %@", error); + return; + } + [self sendToBackend:result.trackingToken]; +}]; + +// When done: +[tracker shutdown]; +``` + ## Privacy The SDK collects the Identifier for Vendor (IDFV) and persists it in the diff --git a/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift b/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift index b827143..6383758 100644 --- a/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift +++ b/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift @@ -21,11 +21,11 @@ final class DeviceDataCollector: @unchecked Sendable { func collect() throws -> DeviceData { let idfv = try resolveIDFV() - let trackingToken = storage.get(forKey: KeychainStorage.trackingTokenKey) + let storedID = storage.get(forKey: KeychainStorage.storedIDKey) return DeviceData( idfv: idfv, - trackingToken: trackingToken, + storedID: storedID, requestDurationMS: nil ) } @@ -39,7 +39,9 @@ final class DeviceDataCollector: @unchecked Sendable { throw MinFraudDeviceError.idfvUnavailable } - if !storage.set(systemIDFV, forKey: KeychainStorage.idfvKey) { + if storage.set(systemIDFV, forKey: KeychainStorage.idfvKey) { + logger?.debug("Cached IDFV in keychain") + } else { logger?.warning("Failed to cache IDFV in keychain") } diff --git a/Sources/MinFraudDevice/Config/SDKConfig.swift b/Sources/MinFraudDevice/Config/SDKConfig.swift index b448f72..f6c2aa1 100644 --- a/Sources/MinFraudDevice/Config/SDKConfig.swift +++ b/Sources/MinFraudDevice/Config/SDKConfig.swift @@ -12,7 +12,7 @@ public struct SDKConfig: Sendable { public let loggingEnabled: Bool /// Automatic collection interval in seconds. Set to `0` to disable, - /// or a value of `300` or greater to enable periodic collection. + /// or a value between `300` and `86400` to enable periodic collection. public let collectionIntervalSeconds: Int /// Creates a new SDK configuration. @@ -22,7 +22,7 @@ public struct SDKConfig: Sendable { /// - serverURL: Custom server URL, or `nil` to use default servers. /// - loggingEnabled: Whether to enable logging. Defaults to `false`. /// - collectionIntervalSeconds: Automatic collection interval in seconds. - /// Must be `0` (disabled) or at least `300`. Defaults to `0`. + /// Must be `0` (disabled) or between `300` and `86400`. Defaults to `0`. public init( accountID: Int, serverURL: URL? = nil, @@ -31,8 +31,9 @@ public struct SDKConfig: Sendable { ) { precondition(accountID > 0, "Account ID must be positive") precondition( - collectionIntervalSeconds == 0 || collectionIntervalSeconds >= 300, - "Collection interval must be 0 (disabled) or at least 300 seconds" + collectionIntervalSeconds == 0 + || (collectionIntervalSeconds >= 300 && collectionIntervalSeconds <= 86_400), + "Collection interval must be 0 (disabled) or between 300 and 86400 seconds" ) self.accountID = accountID self.serverURL = serverURL diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index 7492623..c36cfc7 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -6,19 +6,28 @@ public enum MinFraudDeviceError: Error, LocalizedError, Equatable { /// The Identifier for Vendor (IDFV) could not be obtained from the system or keychain. case idfvUnavailable - /// The server response did not include a tracking token. - case missingTrackingToken - public var errorDescription: String? { switch self { case .idfvUnavailable: return "Unable to obtain an Identifier for Vendor (IDFV)" - case .missingTrackingToken: - return "Server response did not include a tracking token" } } } +extension MinFraudDeviceError: CustomNSError { + public static var errorDomain: String { SDKConfig.identifier } + + public var errorCode: Int { + switch self { + case .idfvUnavailable: return 1 + } + } + + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: errorDescription ?? "Unknown error"] + } +} + /// Main entry point for the MinFraud Device SDK. /// /// Create an instance with an ``SDKConfig``, then call ``collectAndSend()`` @@ -81,28 +90,25 @@ public final class DeviceTracker: @unchecked Sendable { /// Collects device data and sends it to MaxMind servers. /// - /// On success, the tracking token is persisted in the keychain for - /// inclusion in subsequent requests. + /// On success, the stored ID is persisted in the keychain for + /// inclusion in subsequent requests, and returned as a tracking token. /// /// - Returns: A ``TrackingResult`` containing the tracking token. /// - Throws: ``MinFraudDeviceError/idfvUnavailable`` if the device - /// identifier cannot be obtained, ``MinFraudDeviceError/missingTrackingToken`` - /// if the server response does not include a token, or an ``APIError`` - /// if the network request fails. + /// identifier cannot be obtained, or an ``APIError`` + /// if the server returns a non-success status code or the response + /// body cannot be decoded. public func collectAndSend() async throws -> TrackingResult { let deviceData = try collector.collect() let response = try await apiClient.sendDeviceData(deviceData) - guard let token = response.trackingToken, - !token.trimmingCharacters(in: .whitespaces).isEmpty else { - throw MinFraudDeviceError.missingTrackingToken - } - - if storage.set(token, forKey: KeychainStorage.trackingTokenKey) { - logger?.debug("Tracking token saved from server response") + if storage.set(response.storedID, forKey: KeychainStorage.storedIDKey) { + logger?.debug("Cached stored ID from server response in keychain") + } else { + logger?.warning("Failed to cache stored ID in keychain") } - return TrackingResult(trackingToken: token) + return TrackingResult(trackingToken: response.storedID) } /// Cancels automatic collection and releases resources. @@ -140,8 +146,6 @@ public final class DeviceTracker: @unchecked Sendable { } deinit { - lock.lock() - defer { lock.unlock() } automaticCollectionTask?.cancel() } } diff --git a/Sources/MinFraudDevice/Model/DeviceData.swift b/Sources/MinFraudDevice/Model/DeviceData.swift index ac4625c..dff6904 100644 --- a/Sources/MinFraudDevice/Model/DeviceData.swift +++ b/Sources/MinFraudDevice/Model/DeviceData.swift @@ -1,13 +1,13 @@ import Foundation -struct DeviceData: Encodable { +struct DeviceData: Encodable, Sendable { let idfv: String - let trackingToken: String? + let storedID: String? let requestDurationMS: Int? enum CodingKeys: String, CodingKey { case idfv - case trackingToken = "tracking_token" + case storedID = "stored_id" case requestDurationMS = "request_duration" } } diff --git a/Sources/MinFraudDevice/Model/ServerResponse.swift b/Sources/MinFraudDevice/Model/ServerResponse.swift index c5d687c..aadeee8 100644 --- a/Sources/MinFraudDevice/Model/ServerResponse.swift +++ b/Sources/MinFraudDevice/Model/ServerResponse.swift @@ -1,11 +1,45 @@ import Foundation -struct ServerResponse: Decodable { - let trackingToken: String? - let ipVersion: Int? +struct ServerResponse: Decodable, Sendable { + let storedID: String + let ipVersion: Int enum CodingKeys: String, CodingKey { - case trackingToken = "tracking_token" + case storedID = "stored_id" case ipVersion = "ip_version" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let rawStoredID = try container.decode(String.self, forKey: .storedID) + guard !rawStoredID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .storedID, + in: container, + debugDescription: "stored_id must not be blank" + ) + } + self.storedID = rawStoredID + + let rawIPVersion = try container.decode(Int.self, forKey: .ipVersion) + guard rawIPVersion == 4 || rawIPVersion == 6 else { + throw DecodingError.dataCorruptedError( + forKey: .ipVersion, + in: container, + debugDescription: "ip_version must be 4 or 6, got \(rawIPVersion)" + ) + } + self.ipVersion = rawIPVersion + } + + init(storedID: String, ipVersion: Int) { + precondition( + !storedID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "stored_id must not be blank" + ) + precondition(ipVersion == 4 || ipVersion == 6, "ip_version must be 4 or 6") + self.storedID = storedID + self.ipVersion = ipVersion + } } diff --git a/Sources/MinFraudDevice/Model/TrackingResult.swift b/Sources/MinFraudDevice/Model/TrackingResult.swift index 1950c01..ca7f569 100644 --- a/Sources/MinFraudDevice/Model/TrackingResult.swift +++ b/Sources/MinFraudDevice/Model/TrackingResult.swift @@ -12,7 +12,10 @@ public struct TrackingResult: Sendable, CustomStringConvertible { public let trackingToken: String init(trackingToken: String) { - precondition(!trackingToken.trimmingCharacters(in: .whitespaces).isEmpty, "Tracking token must not be blank") + precondition( + !trackingToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "Tracking token must not be blank" + ) self.trackingToken = trackingToken } diff --git a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift index dd5f123..d3d5b58 100644 --- a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift +++ b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift @@ -21,10 +21,36 @@ public enum APIError: Error, LocalizedError { /// The server returned a non-success HTTP status code. case serverError(statusCode: Int, message: String) + /// The server returned a success status code but the response body + /// could not be decoded. + case responseDecodingFailed(String) + public var errorDescription: String? { switch self { case .serverError(let statusCode, let message): return "Server returned \(statusCode): \(message)" + case .responseDecodingFailed(let detail): + return "Failed to decode server response: \(detail)" + } + } +} + +extension APIError: CustomNSError { + public static var errorDomain: String { "\(SDKConfig.identifier).api" } + + public var errorCode: Int { + switch self { + case .serverError(let statusCode, _): return statusCode + case .responseDecodingFailed: return -1 + } + } + + public var errorUserInfo: [String: Any] { + switch self { + case .serverError(_, let message): + return [NSLocalizedDescriptionKey: errorDescription ?? message] + case .responseDecodingFailed: + return [NSLocalizedDescriptionKey: errorDescription ?? "Unknown decoding error"] } } } @@ -52,7 +78,7 @@ final class DeviceAPIClient: Sendable { private func sendWithDualRequest(_ deviceData: DeviceData) async throws -> ServerResponse { let ipv6URL = URL(string: "https://\(SDKConfig.defaultIPv6Host)\(SDKConfig.endpointPath)")! - // Use a monotonic approach for calculating request duration. + // ProcessInfo.systemUptime uses a monotonic clock, and is therefore immune to wall-clock drift from NTP adjustments. let startTime = ProcessInfo.processInfo.systemUptime let ipv6Response = try await sendToURL(deviceData, url: ipv6URL) let requestDurationMS = Int((ProcessInfo.processInfo.systemUptime - startTime) * 1000) @@ -61,7 +87,7 @@ final class DeviceAPIClient: Sendable { let ipv4URL = URL(string: "https://\(SDKConfig.defaultIPv4Host)\(SDKConfig.endpointPath)")! let dataWithDuration = DeviceData( idfv: deviceData.idfv, - trackingToken: deviceData.trackingToken, + storedID: deviceData.storedID, requestDurationMS: requestDurationMS ) do { @@ -97,7 +123,11 @@ final class DeviceAPIClient: Sendable { throw APIError.serverError(statusCode: httpResponse.statusCode, message: message) } - let decoder = JSONDecoder() - return try decoder.decode(ServerResponse.self, from: data) + do { + let decoder = JSONDecoder() + return try decoder.decode(ServerResponse.self, from: data) + } catch { + throw APIError.responseDecodingFailed(String(describing: error)) + } } } diff --git a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift new file mode 100644 index 0000000..7e0a50e --- /dev/null +++ b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Objective-C compatible entry point for the MinFraud Device SDK. +/// +/// This class wraps ``DeviceTracker`` for use from Objective-C code. +/// Swift callers should use ``DeviceTracker`` directly. +@objc(MMDeviceTracker) +public final class ObjCDeviceTracker: NSObject { + private let tracker: DeviceTracker + private var isShutDown = false + private let lock = NSLock() + + /// Creates a new device tracker with the given configuration. + /// + /// - Parameter config: The SDK configuration. + @objc + public init(config: ObjCSDKConfig) { + self.tracker = DeviceTracker(config: config.config) + } + + /// Collects device data and sends it to MaxMind servers. + /// + /// On success, the completion handler receives an ``MMTrackingResult`` + /// containing the tracking token. On failure, it receives an `NSError`. + /// + /// The completion handler is always called exactly once unless + /// ``shutdown`` is called before the operation completes. The tracker + /// is kept alive until the operation finishes, even if all other + /// references are released. + /// + /// - Parameter completion: Called on the main queue with the result or error. + @objc + public func collectAndSend(completion: @escaping (ObjCTrackingResult?, NSError?) -> Void) { + Task { @MainActor in + do { + let result = try await self.tracker.collectAndSend() + self.lock.lock() + let shutDown = self.isShutDown + self.lock.unlock() + guard !shutDown else { return } + completion(ObjCTrackingResult(result: result), nil) + } catch { + self.lock.lock() + let shutDown = self.isShutDown + self.lock.unlock() + guard !shutDown else { return } + completion(nil, error as NSError) + } + } + } + + /// Cancels automatic collection and releases resources. + /// + /// Any in-flight ``collectAndSend`` call will complete silently + /// without invoking its completion handler. + @objc + public func shutdown() { + lock.lock() + defer { lock.unlock() } + isShutDown = true + tracker.shutdown() + } +} diff --git a/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift new file mode 100644 index 0000000..efff9e5 --- /dev/null +++ b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Objective-C compatible configuration for the MinFraud Device SDK. +/// +/// This class wraps ``SDKConfig`` for use from Objective-C code. +/// Swift callers should use ``SDKConfig`` directly. +@objc(MMSDKConfig) +public final class ObjCSDKConfig: NSObject { + let config: SDKConfig + + /// Creates a new SDK configuration with all options. + /// + /// Returns `nil` if `accountID` is not positive or + /// `collectionIntervalSeconds` is not `0` or between `300` and `86400`. + /// + /// - Parameters: + /// - accountID: Your MaxMind account ID. Must be positive. + /// - serverURL: Custom server URL, or `nil` to use default servers. + /// - loggingEnabled: Whether to enable logging. + /// - collectionIntervalSeconds: Automatic collection interval in seconds. + /// Must be `0` (disabled) or between `300` and `86400`. + @objc + public init?( + accountID: Int, + serverURL: URL?, + loggingEnabled: Bool, + collectionIntervalSeconds: Int + ) { + guard accountID > 0, + collectionIntervalSeconds == 0 + || (collectionIntervalSeconds >= 300 && collectionIntervalSeconds <= 86_400) + else { + return nil + } + self.config = SDKConfig( + accountID: accountID, + serverURL: serverURL, + loggingEnabled: loggingEnabled, + collectionIntervalSeconds: collectionIntervalSeconds + ) + } + + /// Creates a new SDK configuration with default options. + /// + /// Returns `nil` if `accountID` is not positive. + /// + /// - Parameter accountID: Your MaxMind account ID. Must be positive. + @objc + public convenience init?(accountID: Int) { + self.init( + accountID: accountID, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 0 + ) + } +} diff --git a/Sources/MinFraudDevice/ObjC/MMTrackingResult.swift b/Sources/MinFraudDevice/ObjC/MMTrackingResult.swift new file mode 100644 index 0000000..1a62585 --- /dev/null +++ b/Sources/MinFraudDevice/ObjC/MMTrackingResult.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Objective-C compatible result of a device tracking operation. +/// +/// This class wraps ``TrackingResult`` for use from Objective-C code. +/// Swift callers should use ``TrackingResult`` directly. +@objc(MMTrackingResult) +public final class ObjCTrackingResult: NSObject { + /// Opaque tracking token to pass to the minFraud API. + @objc public let trackingToken: String + + init(result: TrackingResult) { + self.trackingToken = result.trackingToken + } + + public override var description: String { + "MMTrackingResult(trackingToken: )" + } +} diff --git a/Sources/MinFraudDevice/Storage/KeychainStorage.swift b/Sources/MinFraudDevice/Storage/KeychainStorage.swift index 2743c1d..81e1bcc 100644 --- a/Sources/MinFraudDevice/Storage/KeychainStorage.swift +++ b/Sources/MinFraudDevice/Storage/KeychainStorage.swift @@ -11,7 +11,7 @@ final class KeychainStorage: KeychainStoring, @unchecked Sendable { static let service = SDKConfig.identifier static let idfvKey = "idfv" - static let trackingTokenKey = "tracking_token" + static let storedIDKey = "stored_id" private let logger: Logger? diff --git a/Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m b/Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m new file mode 100644 index 0000000..b20eb33 --- /dev/null +++ b/Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m @@ -0,0 +1,44 @@ +@import XCTest; +@import MinFraudDevice; + +@interface ObjCWrapperTests : XCTestCase +@end + +@implementation ObjCWrapperTests + +- (void)testSDKConfigCreationWithDefaults { + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345]; + XCTAssertNotNil(config); +} + +- (void)testSDKConfigCreationWithAllOptions { + NSURL *url = [NSURL URLWithString:@"https://custom.example.com"]; + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345 + serverURL:url + loggingEnabled:YES + collectionIntervalSeconds:300]; + XCTAssertNotNil(config); +} + +- (void)testDeviceTrackerCreation { + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345]; + MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; + XCTAssertNotNil(tracker); + [tracker shutdown]; +} + +- (void)testTrackingResultDescription { + // We can't easily construct an MMTrackingResult from ObjC (init is internal), + // but we can verify the class is visible and the API compiles. + XCTAssertTrue([MMTrackingResult instancesRespondToSelector:@selector(trackingToken)]); +} + +- (void)testCollectAndSendSignature { + // Verify the completion-handler API is callable from ObjC. + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345]; + MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; + XCTAssertTrue([tracker respondsToSelector:@selector(collectAndSendWithCompletion:)]); + [tracker shutdown]; +} + +@end diff --git a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift index f1c20ae..b9bb386 100644 --- a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift @@ -18,7 +18,7 @@ final class DeviceAPIClientTests: XCTestCase { } private var testDeviceData: DeviceData { - DeviceData(idfv: "test-idfv", trackingToken: "test-token", requestDurationMS: nil) + DeviceData(idfv: "test-idfv", storedID: "test-stored-id", requestDurationMS: nil) } private func makeClient( @@ -29,24 +29,37 @@ final class DeviceAPIClientTests: XCTestCase { return DeviceAPIClient(config: config, session: mockSession ?? .shared) } - func testSendDeviceDataSuccessResponses() async throws { + func testSendDeviceDataSuccess() async throws { + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data("{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}".utf8)) + } + + let client = makeClient() + let response = try await client.sendDeviceData(testDeviceData) + + XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.ipVersion, 6) + } + + func testSendDeviceDataThrowsOnInvalidResponse() async { struct Case { let label: String let json: String - let expectedToken: String? - let expectedIPVersion: Int? } let cases: [Case] = [ - Case(label: "valid values", - json: "{\"tracking_token\":\"abc123:hmac456\",\"ip_version\":6}", - expectedToken: "abc123:hmac456", expectedIPVersion: 6), - Case(label: "null values", - json: "{\"tracking_token\":null,\"ip_version\":null}", - expectedToken: nil, expectedIPVersion: nil), - Case(label: "empty object", - json: "{}", - expectedToken: nil, expectedIPVersion: nil) + Case(label: "missing stored_id", json: "{\"ip_version\":6}"), + Case(label: "null stored_id", json: "{\"stored_id\":null,\"ip_version\":6}"), + Case(label: "blank stored_id", json: "{\"stored_id\":\" \",\"ip_version\":6}"), + Case(label: "missing ip_version", json: "{\"stored_id\":\"abc\"}"), + Case(label: "invalid ip_version", json: "{\"stored_id\":\"abc\",\"ip_version\":5}"), + Case(label: "empty object", json: "{}") ] for tc in cases { @@ -61,10 +74,14 @@ final class DeviceAPIClientTests: XCTestCase { } let client = makeClient() - let response = try await client.sendDeviceData(testDeviceData) - - XCTAssertEqual(response.trackingToken, tc.expectedToken, "Failed for case: \(tc.label)") - XCTAssertEqual(response.ipVersion, tc.expectedIPVersion, "Failed for case: \(tc.label)") + do { + _ = try await client.sendDeviceData(testDeviceData) + XCTFail("Expected error for case: \(tc.label)") + } catch is APIError { + // Expected: APIError.responseDecodingFailed + } catch { + XCTFail("Expected APIError.responseDecodingFailed for case \(tc.label), got: \(error)") + } } } @@ -112,7 +129,7 @@ final class DeviceAPIClientTests: XCTestCase { capturedContentType = request.value(forHTTPHeaderField: "Content-Type") capturedURL = request.url let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":4} + {"stored_id":"abc123:hmac456","ip_version":4} """.utf8) let response = HTTPURLResponse( url: request.url!, @@ -131,40 +148,39 @@ final class DeviceAPIClientTests: XCTestCase { XCTAssertNotNil(capturedBody) XCTAssertEqual(capturedBody?["account_id"] as? Int, 12345) XCTAssertEqual(capturedBody?["idfv"] as? String, "test-idfv") - XCTAssertEqual(capturedBody?["tracking_token"] as? String, "test-token") + XCTAssertEqual(capturedBody?["stored_id"] as? String, "test-stored-id") } func testRequestBodyEncodesDeviceDataFieldsFlat() throws { - let deviceData = DeviceData(idfv: "test-idfv", trackingToken: "test-token", requestDurationMS: 42) + let deviceData = DeviceData(idfv: "test-idfv", storedID: "test-stored-id", requestDurationMS: 42) let body = RequestBody(accountID: 123, deviceData: deviceData) let data = try JSONEncoder().encode(body) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) XCTAssertEqual(json["account_id"] as? Int, 123) XCTAssertEqual(json["idfv"] as? String, "test-idfv") - XCTAssertEqual(json["tracking_token"] as? String, "test-token") + XCTAssertEqual(json["stored_id"] as? String, "test-stored-id") XCTAssertEqual(json["request_duration"] as? Int, 42) XCTAssertNil(json["deviceData"], "DeviceData fields should be flat, not nested") } // MARK: - Dual Request Tests + private var validIPv6Response: String { + "{\"stored_id\":\"ipv6-stored-id\",\"ip_version\":6}" + } + + private var validIPv4Response: String { + "{\"stored_id\":\"ipv4-stored-id\",\"ip_version\":4}" + } + func testDualRequestSendsToIPv6AndIPv4Endpoints() async throws { var capturedURLs: [String] = [] var requestCount = 0 MockURLProtocol.requestHandler = { request in requestCount += 1 capturedURLs.append(request.url?.absoluteString ?? "") - let body: String - if requestCount == 1 { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":6} - """ - } else { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":4} - """ - } + let body = requestCount == 1 ? self.validIPv6Response : self.validIPv4Response let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -175,11 +191,12 @@ final class DeviceAPIClientTests: XCTestCase { } let client = makeClient(serverURL: nil) - _ = try await client.sendDeviceData(testDeviceData) + let result = try await client.sendDeviceData(testDeviceData) XCTAssertEqual(capturedURLs.count, 2) XCTAssertTrue(capturedURLs[0].contains("d-ipv6.mmapiws.com")) XCTAssertTrue(capturedURLs[1].contains("d-ipv4.mmapiws.com")) + XCTAssertEqual(result.storedID, "ipv6-stored-id") } func testDualRequestIncludesRequestDurationOnIPv4Only() async throws { @@ -191,16 +208,7 @@ final class DeviceAPIClientTests: XCTestCase { let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] { capturedBodies.append(json) } - let body: String - if requestCount == 1 { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":6} - """ - } else { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":4} - """ - } + let body = requestCount == 1 ? self.validIPv6Response : self.validIPv4Response let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -215,19 +223,15 @@ final class DeviceAPIClientTests: XCTestCase { XCTAssertEqual(capturedBodies.count, 2) XCTAssertNil(capturedBodies[0]["request_duration"]) - XCTAssertNotNil(capturedBodies[1]["request_duration"]) - if let duration = capturedBodies[1]["request_duration"] as? Int { - XCTAssertGreaterThanOrEqual(duration, 0) - } + let duration = try XCTUnwrap(capturedBodies[1]["request_duration"] as? Int) + XCTAssertGreaterThanOrEqual(duration, 0) } func testDualRequestSkipsIPv4WhenIPVersionIsNot6() async throws { var requestCount = 0 MockURLProtocol.requestHandler = { request in requestCount += 1 - let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":4} - """.utf8) + let data = Data(self.validIPv4Response.utf8) let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -248,9 +252,7 @@ final class DeviceAPIClientTests: XCTestCase { MockURLProtocol.requestHandler = { request in requestCount += 1 if requestCount == 1 { - let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":6} - """.utf8) + let data = Data(self.validIPv6Response.utf8) let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -259,9 +261,7 @@ final class DeviceAPIClientTests: XCTestCase { )! return (response, data) } else { - let data = Data(""" - {"error":"Server Error"} - """.utf8) + let data = Data("{\"error\":\"Server Error\"}".utf8) let response = HTTPURLResponse( url: request.url!, statusCode: 500, @@ -275,7 +275,7 @@ final class DeviceAPIClientTests: XCTestCase { let client = makeClient(serverURL: nil) let response = try await client.sendDeviceData(testDeviceData) - XCTAssertEqual(response.trackingToken, "abc123:hmac456") + XCTAssertEqual(response.storedID, "ipv6-stored-id") XCTAssertEqual(requestCount, 2) } } diff --git a/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift b/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift index a0a713a..76ac6e9 100644 --- a/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift @@ -60,9 +60,23 @@ final class DeviceDataCollectorTests: XCTestCase { } } - func testCollectIncludesTrackingTokenFromKeychain() throws { + func testCollectReturnsIDFVEvenWhenCachingFails() throws { let storage = MockKeychainStorage() - _ = storage.set("existing-token", forKey: KeychainStorage.trackingTokenKey) + storage.shouldFailOnSet = true + let collector = DeviceDataCollector( + storage: storage, + idfvProvider: { "SYSTEM-IDFV-123" } + ) + + let data = try collector.collect() + + XCTAssertEqual(data.idfv, "SYSTEM-IDFV-123") + XCTAssertNil(storage.get(forKey: KeychainStorage.idfvKey)) + } + + func testCollectIncludesStoredIDFromKeychain() throws { + let storage = MockKeychainStorage() + _ = storage.set("existing-stored-id", forKey: KeychainStorage.storedIDKey) let collector = DeviceDataCollector( storage: storage, idfvProvider: { "IDFV" } @@ -70,10 +84,10 @@ final class DeviceDataCollectorTests: XCTestCase { let data = try collector.collect() - XCTAssertEqual(data.trackingToken, "existing-token") + XCTAssertEqual(data.storedID, "existing-stored-id") } - func testCollectReturnsNilTrackingTokenWhenNotStored() throws { + func testCollectReturnsNilStoredIDWhenNotStored() throws { let storage = MockKeychainStorage() let collector = DeviceDataCollector( storage: storage, @@ -82,7 +96,7 @@ final class DeviceDataCollectorTests: XCTestCase { let data = try collector.collect() - XCTAssertNil(data.trackingToken) + XCTAssertNil(data.storedID) } func testCollectReturnsNilRequestDuration() throws { diff --git a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift index dc4c30e..c722b8e 100644 --- a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift @@ -22,6 +22,10 @@ final class DeviceTrackerTests: XCTestCase { super.tearDown() } + private var validResponse: String { + "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}" + } + private func makeTracker( accountID: Int = 99999, serverURL: URL? = URL(string: "https://test.maxmind.com"), @@ -49,16 +53,13 @@ final class DeviceTrackerTests: XCTestCase { func testCollectAndSendReturnsTrackingResult() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":6} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } let tracker = makeTracker() @@ -67,88 +68,32 @@ final class DeviceTrackerTests: XCTestCase { XCTAssertEqual(result.trackingToken, "abc123:hmac456") } - func testCollectAndSendSavesTrackingTokenToKeychain() async throws { + func testCollectAndSendSavesStoredIDToKeychain() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"saved-token"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } let tracker = makeTracker() _ = try await tracker.collectAndSend() - XCTAssertEqual(mockStorage.get(forKey: KeychainStorage.trackingTokenKey), "saved-token") - } - - func testCollectAndSendThrowsOnMissingTrackingToken() async { - MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":null} - """.utf8) - let response = HTTPURLResponse( - url: URL(string: "https://test.maxmind.com")!, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - return (response, data) - } - - let tracker = makeTracker() - do { - _ = try await tracker.collectAndSend() - XCTFail("Expected error to be thrown") - } catch is MinFraudDeviceError { - // Expected - } catch { - XCTFail("Unexpected error type: \(error)") - } - } - - func testCollectAndSendThrowsOnBlankTrackingToken() async { - MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":" "} - """.utf8) - let response = HTTPURLResponse( - url: URL(string: "https://test.maxmind.com")!, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - return (response, data) - } - - let tracker = makeTracker() - do { - _ = try await tracker.collectAndSend() - XCTFail("Expected error to be thrown") - } catch is MinFraudDeviceError { - // Expected - } catch { - XCTFail("Unexpected error type: \(error)") - } + XCTAssertEqual(mockStorage.get(forKey: KeychainStorage.storedIDKey), "abc123:hmac456") } func testCollectAndSendSucceedsWhenStorageSaveFails() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"abc123:hmac456"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } mockStorage.shouldFailOnSet = true @@ -156,20 +101,18 @@ final class DeviceTrackerTests: XCTestCase { let result = try await tracker.collectAndSend() XCTAssertEqual(result.trackingToken, "abc123:hmac456") + XCTAssertNil(mockStorage.get(forKey: KeychainStorage.storedIDKey)) } func testCollectAndSendPropagatesAPIError() async { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"error":"Server Error"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 500, httpVersion: nil, headerFields: nil )! - return (response, data) + return (response, Data("{}".utf8)) } let tracker = makeTracker() @@ -216,30 +159,54 @@ final class DeviceTrackerTests: XCTestCase { // MARK: - Shutdown + func testAutomaticCollectionSendsRequest() async throws { + let requestReceived = expectation(description: "Automatic collection sent a request") + + MockURLProtocol.requestHandler = { _ in + requestReceived.fulfill() + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data(self.validResponse.utf8)) + } + + let tracker = makeTracker(collectionIntervalSeconds: 300) + + await fulfillment(of: [requestReceived], timeout: 2) + tracker.shutdown() + } + func testShutdownCancelsAutomaticCollection() async throws { + let secondRequest = expectation(description: "Second automatic collection request") + secondRequest.isInverted = true + + var requestCount = 0 MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"test"} - """.utf8) + requestCount += 1 + if requestCount >= 2 { + secondRequest.fulfill() + } let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } let tracker = makeTracker(collectionIntervalSeconds: 300) - // Give the auto-collection task a moment to start - try await Task.sleep(nanoseconds: 50_000_000) // 50ms + // Wait for the first automatic collection to complete. + try await Task.sleep(nanoseconds: 500_000_000) tracker.shutdown() - // After shutdown, the task should be cancelled - // Give it time to settle and verify no crashes - try await Task.sleep(nanoseconds: 200_000_000) // 200ms + // Verify no further requests are made after shutdown. + await fulfillment(of: [secondRequest], timeout: 1) } // MARK: - Public Init diff --git a/Tests/MinFraudDeviceTests/ModelTests.swift b/Tests/MinFraudDeviceTests/ModelTests.swift index 329c369..fd2e94c 100644 --- a/Tests/MinFraudDeviceTests/ModelTests.swift +++ b/Tests/MinFraudDeviceTests/ModelTests.swift @@ -6,23 +6,23 @@ final class ModelTests: XCTestCase { func testDeviceDataEncoding() throws { struct Case { let label: String - let trackingToken: String? + let storedID: String? let requestDurationMS: Int? - let expectedToken: String? + let expectedStoredID: String? let expectedDuration: Int? } let cases: [Case] = [ - Case(label: "all fields", trackingToken: "abc123:hmac456", - requestDurationMS: 42, expectedToken: "abc123:hmac456", expectedDuration: 42), - Case(label: "nil fields omitted", trackingToken: nil, - requestDurationMS: nil, expectedToken: nil, expectedDuration: nil) + Case(label: "all fields", storedID: "abc123:hmac456", + requestDurationMS: 42, expectedStoredID: "abc123:hmac456", expectedDuration: 42), + Case(label: "nil fields omitted", storedID: nil, + requestDurationMS: nil, expectedStoredID: nil, expectedDuration: nil) ] for tc in cases { let data = DeviceData( idfv: "test-idfv", - trackingToken: tc.trackingToken, + storedID: tc.storedID, requestDurationMS: tc.requestDurationMS ) let encoded = try JSONEncoder().encode(data) @@ -32,40 +32,55 @@ final class ModelTests: XCTestCase { ) XCTAssertEqual(json["idfv"] as? String, "test-idfv", "Failed for case: \(tc.label)") - XCTAssertEqual(json["tracking_token"] as? String, tc.expectedToken, "Failed for case: \(tc.label)") + XCTAssertEqual(json["stored_id"] as? String, tc.expectedStoredID, "Failed for case: \(tc.label)") XCTAssertEqual(json["request_duration"] as? Int, tc.expectedDuration, "Failed for case: \(tc.label)") } } func testServerResponseDecoding() throws { - struct Case { - let label: String - let json: String - let expectedToken: String? - let expectedIPVersion: Int? - } + let json = "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}" + let response = try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8)) - let cases: [Case] = [ - Case(label: "valid values", - json: "{\"tracking_token\":\"abc123:hmac456\",\"ip_version\":6}", - expectedToken: "abc123:hmac456", expectedIPVersion: 6), - Case(label: "null values", - json: "{\"tracking_token\":null,\"ip_version\":null}", - expectedToken: nil, expectedIPVersion: nil), - Case(label: "missing values", - json: "{}", - expectedToken: nil, expectedIPVersion: nil), - Case(label: "unknown fields ignored", - json: "{\"tracking_token\":\"abc123:hmac456\",\"ip_version\":6,\"unknown\":\"value\"}", - expectedToken: "abc123:hmac456", expectedIPVersion: 6) - ] + XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.ipVersion, 6) + } - for tc in cases { - let response = try JSONDecoder().decode(ServerResponse.self, from: Data(tc.json.utf8)) + func testServerResponseIgnoresUnknownFields() throws { + let json = "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6,\"unknown\":\"value\"}" + let response = try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8)) - XCTAssertEqual(response.trackingToken, tc.expectedToken, "Failed for case: \(tc.label)") - XCTAssertEqual(response.ipVersion, tc.expectedIPVersion, "Failed for case: \(tc.label)") - } + XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.ipVersion, 6) + } + + func testServerResponseRejectsMissingStoredID() { + let json = "{\"ip_version\":6}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsNullStoredID() { + let json = "{\"stored_id\":null,\"ip_version\":6}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsBlankStoredID() { + let json = "{\"stored_id\":\" \",\"ip_version\":6}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsMissingIPVersion() { + let json = "{\"stored_id\":\"abc123:hmac456\"}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsInvalidIPVersion() { + let json = "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":5}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsEmptyObject() { + let json = "{}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) } func testTrackingResultStoresToken() { diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift new file mode 100644 index 0000000..8ad49e9 --- /dev/null +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import MinFraudDevice + +final class ObjCWrapperTests: XCTestCase { + override func setUp() { + super.setUp() + URLProtocol.registerClass(MockURLProtocol.self) + } + + override func tearDown() { + URLProtocol.unregisterClass(MockURLProtocol.self) + MockURLProtocol.requestHandler = nil + super.tearDown() + } + + // MARK: - MMSDKConfig + + func testObjCSDKConfigDefaults() { + let config = ObjCSDKConfig(accountID: 12345) + XCTAssertNotNil(config) + XCTAssertEqual(config?.config.accountID, 12345) + XCTAssertNil(config?.config.serverURL) + XCTAssertFalse(config!.config.loggingEnabled) + XCTAssertEqual(config?.config.collectionIntervalSeconds, 0) + } + + func testObjCSDKConfigAllOptions() { + let url = URL(string: "https://custom.example.com")! + let config = ObjCSDKConfig( + accountID: 99999, + serverURL: url, + loggingEnabled: true, + collectionIntervalSeconds: 300 + ) + XCTAssertNotNil(config) + XCTAssertEqual(config?.config.accountID, 99999) + XCTAssertEqual(config?.config.serverURL, url) + XCTAssertTrue(config!.config.loggingEnabled) + XCTAssertEqual(config?.config.collectionIntervalSeconds, 300) + } + + func testObjCSDKConfigReturnsNilForInvalidAccountID() { + XCTAssertNil(ObjCSDKConfig(accountID: 0)) + XCTAssertNil(ObjCSDKConfig(accountID: -1)) + } + + func testObjCSDKConfigReturnsNilForInvalidCollectionInterval() { + XCTAssertNil( + ObjCSDKConfig( + accountID: 12345, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 60 + ) + ) + XCTAssertNil( + ObjCSDKConfig( + accountID: 12345, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 299 + ) + ) + XCTAssertNil( + ObjCSDKConfig( + accountID: 12345, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 86401 + ) + ) + } + + // MARK: - MMDeviceTracker collectAndSend success + + func testObjCCollectAndSendSuccess() { + MockURLProtocol.requestHandler = { _ in + let data = Data("{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}".utf8) + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, + loggingEnabled: false, collectionIntervalSeconds: 0)! + let tracker = ObjCDeviceTracker(config: config) + let expectation = expectation(description: "collectAndSend completes") + + tracker.collectAndSend { result, error in + XCTAssertNil(error) + XCTAssertNotNil(result) + XCTAssertEqual(result?.trackingToken, "abc123:hmac456") + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + tracker.shutdown() + } + + // MARK: - MMDeviceTracker collectAndSend failure + + func testObjCCollectAndSendServerError() { + MockURLProtocol.requestHandler = { _ in + let data = Data("{\"error\":\"Server Error\"}".utf8) + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + return (response, data) + } + + let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, + loggingEnabled: false, collectionIntervalSeconds: 0)! + let tracker = ObjCDeviceTracker(config: config) + let expectation = expectation(description: "collectAndSend fails") + + tracker.collectAndSend { result, error in + XCTAssertNil(result) + XCTAssertNotNil(error) + XCTAssertEqual(error?.domain, "\(SDKConfig.identifier).api") + XCTAssertEqual(error?.code, 500) + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + tracker.shutdown() + } + + // MARK: - MMDeviceTracker shutdown suppresses callback + + func testObjCShutdownSuppressesCompletionHandler() { + // Use a handler that blocks, keeping the request in-flight. + MockURLProtocol.requestHandler = { _ in + Thread.sleep(forTimeInterval: 5) + throw URLError(.cancelled) + } + + let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, + loggingEnabled: false, collectionIntervalSeconds: 0)! + let tracker = ObjCDeviceTracker(config: config) + + var completionCalled = false + tracker.collectAndSend { _, _ in + completionCalled = true + } + + tracker.shutdown() + + let expectation = expectation(description: "wait for potential callback") + expectation.isInverted = true + waitForExpectations(timeout: 1) + + XCTAssertFalse(completionCalled) + } + + // MARK: - MMTrackingResult + + func testObjCTrackingResultRedactsDescription() { + let result = ObjCTrackingResult(result: TrackingResult(trackingToken: "secret")) + XCTAssertEqual(result.trackingToken, "secret") + XCTAssertFalse(result.description.contains("secret")) + } + + // MARK: - CustomNSError bridging + + func testMinFraudDeviceErrorBridgesToNSError() { + let error = MinFraudDeviceError.idfvUnavailable as NSError + XCTAssertEqual(error.domain, SDKConfig.identifier) + XCTAssertEqual(error.code, 1) + XCTAssertTrue(error.localizedDescription.contains("IDFV")) + } + + func testAPIErrorServerErrorBridgesToNSError() { + let error = APIError.serverError(statusCode: 403, message: "Forbidden") as NSError + XCTAssertEqual(error.domain, "\(SDKConfig.identifier).api") + XCTAssertEqual(error.code, 403) + } + + func testAPIErrorResponseDecodingFailedBridgesToNSError() { + let error = APIError.responseDecodingFailed("missing field") as NSError + XCTAssertEqual(error.domain, "\(SDKConfig.identifier).api") + XCTAssertEqual(error.code, -1) + XCTAssertTrue(error.localizedDescription.contains("missing field")) + } +}