diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 16e6803cf..20cca93f3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -94,10 +94,13 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */; }; + A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */; }; + A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */; }; A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */; }; + A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1909,10 +1912,13 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionService.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsRequests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendService.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionServiceTests.swift; sourceTree = ""; }; + A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSSessionsBackendServiceTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2418,6 +2424,8 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, A5162F01A1B2C3D4E5F60001 /* OSSessionService.swift */, + A5162F01A1B2C3D4E5F60013 /* OSSessionsRequests.swift */, + A5162F01A1B2C3D4E5F60011 /* OSSessionsBackendService.swift */, A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, @@ -2747,6 +2755,7 @@ A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, A5162F01A1B2C3D4E5F60003 /* OSSessionServiceTests.swift */, + A5162F01A1B2C3D4E5F60015 /* OSSessionsBackendServiceTests.swift */, A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, @@ -4667,6 +4676,8 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, A5162F01A1B2C3D4E5F60002 /* OSSessionService.swift in Sources */, + A5162F01A1B2C3D4E5F60014 /* OSSessionsRequests.swift in Sources */, + A5162F01A1B2C3D4E5F60012 /* OSSessionsBackendService.swift in Sources */, A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, @@ -4864,6 +4875,7 @@ A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, A5162F01A1B2C3D4E5F60004 /* OSSessionServiceTests.swift in Sources */, + A5162F01A1B2C3D4E5F60016 /* OSSessionsBackendServiceTests.swift in Sources */, A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift new file mode 100644 index 000000000..e9d35944e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsBackendService.swift @@ -0,0 +1,166 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +struct OSCreateSessionRequestBody: Equatable { + let onesignalId: String + let subscriptionId: String + let startTime: Date + let idempotencyKey: String + let directAttributionId: String? + + init(onesignalId: String, subscriptionId: String, startTime: Date, idempotencyKey: String, directAttributionId: String? = nil) { + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + self.startTime = startTime + self.idempotencyKey = idempotencyKey + self.directAttributionId = directAttributionId + } +} + +struct OSUpdateSessionRequestBody: Equatable { + let onesignalId: String + let subscriptionId: String + /// Cumulative for the whole session, so a retried or reordered update cannot double count. + let durationSeconds: Int64 + let idempotencyKey: String + /// Set only on the update that ends the session. + let endTime: Date? + + init(onesignalId: String, subscriptionId: String, durationSeconds: Int64, idempotencyKey: String, endTime: Date? = nil) { + self.onesignalId = onesignalId + self.subscriptionId = subscriptionId + self.durationSeconds = durationSeconds + self.idempotencyKey = idempotencyKey + self.endTime = endTime + } +} + +enum OSSessionsApiResult { + case success(Value) + /// No response, 5xx, 408, 429, or a create success without a session ID. + /// Wait at least `retryAfterSeconds` when set. + case retry(statusCode: Int, retryAfterSeconds: Int?) + /// Any other 4xx, or a request the client rejected before sending. It will never succeed as sent. + case drop(statusCode: Int) +} + +extension OSSessionsApiResult: Equatable where Value: Equatable {} + +/// Typed client for the sessions API. +final class OSSessionsBackendService { + static let defaultRetryAfterSeconds = 60 + + private let client: () -> IOneSignalClient + + convenience init() { + self.init(client: { OneSignalCoreImpl.sharedClient() }) + } + + init(client: @escaping () -> IOneSignalClient) { + self.client = client + } + + /// On success the result holds the backend session ID. + func createSession( + appId: String, + body: OSCreateSessionRequestBody, + completion: @escaping (OSSessionsApiResult) -> Void + ) { + let request = OSRequestCreateSession(appId: appId, body: body) + client().execute(request) { response in + guard let sessionId = Self.sessionId(from: response) else { + // Retrying with the same idempotency key returns the session the backend already created. + OneSignalLog.onesignalLog(.LL_WARN, message: "OSSessionsBackendService: create session response is missing data.session_id") + completion(.retry(statusCode: Self.statusCode(of: response), retryAfterSeconds: nil)) + return + } + completion(.success(sessionId)) + } onFailure: { error in + completion(Self.classifyFailure(error, emptySuccess: .retry(statusCode: error.code, retryAfterSeconds: nil))) + } + } + + /// Reports the session's cumulative duration, and ends it when `body.endTime` is set. + func updateSession( + appId: String, + sessionId: String, + body: OSUpdateSessionRequestBody, + completion: @escaping (OSSessionsApiResult) -> Void + ) { + let request = OSRequestUpdateSession(appId: appId, sessionId: sessionId, body: body) + client().execute(request) { _ in + completion(.success(())) + } onFailure: { error in + completion(Self.classifyFailure(error, emptySuccess: .success(()))) + } + } + + private static func sessionId(from response: [AnyHashable: Any]?) -> String? { + guard let data = response?["data"] as? [String: Any], + let sessionId = data["session_id"] as? String, + !sessionId.isEmpty + else { + return nil + } + return sessionId + } + + private static func statusCode(of response: [AnyHashable: Any]?) -> Int { + (response?["httpStatusCode"] as? NSNumber)?.intValue ?? 202 + } + + /// `emptySuccess` covers a 2xx whose body failed to parse, which `OneSignalClient` reports as a failure. + private static func classifyFailure( + _ error: OneSignalClientError, + emptySuccess: OSSessionsApiResult + ) -> OSSessionsApiResult { + let code = error.code + if (200..<300).contains(code) { + return emptySuccess + } + // 0 means no HTTP response: no network, timeout, or missing privacy consent. The client's + // only negative code is a missing app ID, which a retry cannot fix. + if code == 0 || code == 408 || code == 429 || code >= 500 { + return .retry(statusCode: code, retryAfterSeconds: retryAfterSeconds(error)) + } + return .drop(statusCode: code) + } + + /// Only the delay-seconds form is supported, not the HTTP-date form. + static func retryAfterSeconds(_ error: OneSignalClientError) -> Int? { + let value = error.responseHeaders?.first { key, _ in + (key as? String)?.caseInsensitiveCompare("Retry-After") == .orderedSame + }?.value + if let value { + return Int("\(value)".trimmingCharacters(in: .whitespaces)) ?? defaultRetryAfterSeconds + } + return error.code == 429 ? defaultRetryAfterSeconds : nil + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift new file mode 100644 index 000000000..a861a09e9 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionsRequests.swift @@ -0,0 +1,89 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +/// RFC 3339 in UTC with milliseconds, e.g. `2023-11-14T22:13:20.000Z`. +enum OSSessionTimestamp { + private static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} + +/// `POST apps/{app_id}/sessions` +final class OSRequestCreateSession: OneSignalRequest { + override var description: String { + "" + } + + init(appId: String, body: OSCreateSessionRequestBody) { + super.init() + var parameters: [String: Any] = [ + "onesignal_id": body.onesignalId, + "subscription_id": body.subscriptionId, + "device_type": DEVICE_TYPE_PUSH, + "start_time": OSSessionTimestamp.string(from: body.startTime), + "idempotency_key": body.idempotencyKey + ] + if let directAttributionId = body.directAttributionId { + parameters["direct_attribution_id"] = directAttributionId + } + self.parameters = parameters + self.method = POST + self.path = "apps/\(appId)/sessions" + } +} + +/// `PATCH apps/{app_id}/sessions/{session_id}` +final class OSRequestUpdateSession: OneSignalRequest { + override var description: String { + "" + } + + init(appId: String, sessionId: String, body: OSUpdateSessionRequestBody) { + super.init() + var parameters: [String: Any] = [ + "onesignal_id": body.onesignalId, + "subscription_id": body.subscriptionId, + "duration_seconds": body.durationSeconds, + "idempotency_key": body.idempotencyKey + ] + if let endTime = body.endTime { + parameters["end_time"] = OSSessionTimestamp.string(from: endTime) + } + self.parameters = parameters + self.method = PATCH + self.path = "apps/\(appId)/sessions/\(sessionId)" + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift new file mode 100644 index 000000000..8fb76ba04 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSSessionsBackendServiceTests.swift @@ -0,0 +1,237 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +@testable import OneSignalOSCore +import XCTest + +private final class FakeClient: NSObject, IOneSignalClient { + enum Outcome { + case success([AnyHashable: Any]?) + case failure(code: Int, headers: [String: String]?) + } + + var outcome: Outcome = .success(nil) + private(set) var requests: [OneSignalRequest] = [] + + func execute( + _ request: OneSignalRequest, + onSuccess successBlock: @escaping OSResultSuccessBlock, + onFailure failureBlock: @escaping OSClientFailureBlock + ) { + requests.append(request) + switch outcome { + case .success(let response): + successBlock(response) + case .failure(let code, let headers): + failureBlock(OneSignalClientError(code: code, message: "failure", responseHeaders: headers, response: nil, underlyingError: nil)) + } + } +} + +final class OSSessionsBackendServiceTests: XCTestCase { + private let createBody = OSCreateSessionRequestBody( + onesignalId: "onesignal-id", + subscriptionId: "subscription-id", + startTime: Date(timeIntervalSince1970: 1_700_000_000), + idempotencyKey: "create-key" + ) + private let updateBody = OSUpdateSessionRequestBody( + onesignalId: "onesignal-id", + subscriptionId: "subscription-id", + durationSeconds: 42, + idempotencyKey: "update-key" + ) + private var client: FakeClient! + private var service: OSSessionsBackendService! + + override func setUp() { + super.setUp() + client = FakeClient() + service = OSSessionsBackendService(client: { [unowned self] in self.client }) + } + + private func create(_ body: OSCreateSessionRequestBody? = nil) -> OSSessionsApiResult? { + var result: OSSessionsApiResult? + service.createSession(appId: "app-id", body: body ?? createBody) { result = $0 } + return result + } + + private func update(_ body: OSUpdateSessionRequestBody? = nil) -> OSSessionsApiResult? { + var result: OSSessionsApiResult? + service.updateSession(appId: "app-id", sessionId: "server-id", body: body ?? updateBody) { result = $0 } + return result + } + + /// `Void` is not `Equatable`, so compare update results through their failure fields. + private func assertUpdate( + _ result: OSSessionsApiResult?, + matches expected: OSSessionsApiResult, + file: StaticString = #filePath, + line: UInt = #line + ) { + let mapped: OSSessionsApiResult? = result.map { + switch $0 { + case .success: return .success("") + case .retry(let statusCode, let retryAfterSeconds): return .retry(statusCode: statusCode, retryAfterSeconds: retryAfterSeconds) + case .drop(let statusCode): return .drop(statusCode: statusCode) + } + } + XCTAssertEqual(mapped, expected, file: file, line: line) + } + + func testCreateSessionPostsRequestBody() { + client.outcome = .success(["data": ["session_id": "server-id"]]) + + _ = create() + + let request = client.requests.first + XCTAssertTrue(request is OSRequestCreateSession) + XCTAssertEqual(request?.method, POST) + XCTAssertEqual(request?.path, "apps/app-id/sessions") + XCTAssertEqual(request?.parameters as NSDictionary?, [ + "onesignal_id": "onesignal-id", + "subscription_id": "subscription-id", + "device_type": 0, + "start_time": "2023-11-14T22:13:20.000Z", + "idempotency_key": "create-key" + ]) + } + + func testCreateSessionIncludesDirectAttributionWhenSet() { + client.outcome = .success(["data": ["session_id": "server-id"]]) + + _ = create(OSCreateSessionRequestBody( + onesignalId: "onesignal-id", + subscriptionId: "subscription-id", + startTime: Date(timeIntervalSince1970: 1_700_000_000), + idempotencyKey: "create-key", + directAttributionId: "notification-id" + )) + + XCTAssertEqual(client.requests.first?.parameters?["direct_attribution_id"] as? String, "notification-id") + } + + func testCreateSessionReturnsSessionIdFrom202() { + client.outcome = .success(["data": ["session_id": "server-id"], "httpStatusCode": 202]) + + XCTAssertEqual(create(), .success("server-id")) + } + + func testCreateSessionRetriesSuccessWithoutSessionId() { + let responses: [[AnyHashable: Any]?] = [ + nil, + ["httpStatusCode": 202], + ["data": [String: Any](), "httpStatusCode": 202], + ["data": ["session_id": ""], "httpStatusCode": 202], + ["data": ["session_id": NSNull()], "httpStatusCode": 202], + ["data": ["session_id": 123], "httpStatusCode": 202], + ["data": "server-id", "httpStatusCode": 202] + ] + for response in responses { + client.outcome = .success(response) + + XCTAssertEqual(create(), .retry(statusCode: 202, retryAfterSeconds: nil), "\(String(describing: response))") + } + } + + func testCreateSessionRetriesUnparsableSuccessBody() { + client.outcome = .failure(code: 202, headers: nil) + + XCTAssertEqual(create(), .retry(statusCode: 202, retryAfterSeconds: nil)) + } + + func testUpdateSessionPatchesRequestBody() { + client.outcome = .success(nil) + + assertUpdate(update(), matches: .success("")) + + let request = client.requests.first + XCTAssertTrue(request is OSRequestUpdateSession) + XCTAssertEqual(request?.method, PATCH) + XCTAssertEqual(request?.path, "apps/app-id/sessions/server-id") + XCTAssertEqual(request?.parameters as NSDictionary?, [ + "onesignal_id": "onesignal-id", + "subscription_id": "subscription-id", + "duration_seconds": 42, + "idempotency_key": "update-key" + ]) + } + + func testUpdateSessionIncludesEndTimeWhenSet() { + _ = update(OSUpdateSessionRequestBody( + onesignalId: "onesignal-id", + subscriptionId: "subscription-id", + durationSeconds: 42, + idempotencyKey: "update-key", + endTime: Date(timeIntervalSince1970: 1_700_000_042.5) + )) + + XCTAssertEqual(client.requests.first?.parameters?["end_time"] as? String, "2023-11-14T22:14:02.500Z") + } + + func testUpdateSessionSucceedsWithUnparsableSuccessBody() { + client.outcome = .failure(code: 202, headers: nil) + + assertUpdate(update(), matches: .success("")) + } + + func testFailuresAreRetriedOnNoResponse5xx408And429() { + for code in [0, 408, 500, 502, 503] { + client.outcome = .failure(code: code, headers: nil) + + XCTAssertEqual(create(), .retry(statusCode: code, retryAfterSeconds: nil), "\(code)") + assertUpdate(update(), matches: .retry(statusCode: code, retryAfterSeconds: nil)) + } + client.outcome = .failure(code: 429, headers: nil) + XCTAssertEqual(create(), .retry(statusCode: 429, retryAfterSeconds: OSSessionsBackendService.defaultRetryAfterSeconds)) + } + + func testOther4xxAndMissingAppIdFailuresAreDropped() { + for code in [-1, 400, 401, 403, 404, 409, 410, 422] { + client.outcome = .failure(code: code, headers: ["Retry-After": "10"]) + + XCTAssertEqual(create(), .drop(statusCode: code), "\(code)") + assertUpdate(update(), matches: .drop(statusCode: code)) + } + } + + func testRetryExposesRetryAfterFromResponse() { + client.outcome = .failure(code: 429, headers: ["Retry-After": "30"]) + XCTAssertEqual(create(), .retry(statusCode: 429, retryAfterSeconds: 30)) + + client.outcome = .failure(code: 503, headers: ["retry-after": "15"]) + assertUpdate(update(), matches: .retry(statusCode: 503, retryAfterSeconds: 15)) + } + + func testUnparsableRetryAfterFallsBackToDefault() { + client.outcome = .failure(code: 503, headers: ["Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"]) + + XCTAssertEqual(create(), .retry(statusCode: 503, retryAfterSeconds: OSSessionsBackendService.defaultRetryAfterSeconds)) + } +}