From 556b3cd1119f7f8193cdca4b72448747c6621afa Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Tue, 26 May 2026 12:38:23 +0100 Subject: [PATCH 1/3] SDK-108 Route registerToken through OfflineRequestProcessor for network-failure retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings registerToken to feature parity with disablePush (shipped under SDK-297): the registration request is now persisted to the offline task queue and replayed when the network returns, instead of being dropped fire-once on transient network failures. Implementation mirrors the SDK-297 disableDevice retrofit: - Adds `register(...)` to `RequestProcessorProtocol` (taking the already-resolved `notificationsEnabled: Bool`, not the async notification-state provider) so both processors can implement it and `sendUsingRequestProcessor` can pick offline vs online at call time. - Adds `RequestIdentifier.registerToken` (sibling to the existing `disableDevice` identifier). - Implements `register(...)` in `OfflineRequestProcessor` using the existing `RequestCreator.createRegisterTokenRequest(...)` + `sendIterableRequest(...)` pattern. - Updates `OnlineRequestProcessor.register(...)` to conform to the new protocol signature and consume the shared identifier; the existing private register helper is folded into the public one. The notification-state wrinkle (parallel to SDK-297's `UserIdentitySnapshot`): `notificationStateProvider.isNotificationsEnabled` is now resolved inside `RequestHandler.register` BEFORE routing to a processor. The resolved bool is captured into the offline task body at schedule time, so a request queued offline and replayed later carries the bool as it was at call time — replay does NOT re-query notification state. Public IterableAPI / InternalIterableAPI surface and Obj-C selectors are unchanged. Tests in `offline-events-tests` cover (a) offline replay with the expected body, and (b) the snapshot semantic — a `MockNotificationStateProvider` flipped from `true` to `false` after register is called still yields `notificationsEnabled: true` on the replayed request. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Request/OfflineRequestProcessor.swift | 16 ++ .../Request/OnlineRequestProcessor.swift | 30 ++-- .../api-client/Request/RequestHandler.swift | 12 +- .../Request/RequestProcessorProtocol.swift | 7 + .../RequestHandlerTests.swift | 144 ++++++++++++++++++ 5 files changed, 184 insertions(+), 25 deletions(-) diff --git a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift index 6d50a1df6..ba473e2ac 100644 --- a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift @@ -41,6 +41,22 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { taskRunner.stop() } + @discardableResult + func register(registerTokenInfo: RegisterTokenInfo, + notificationsEnabled: Bool, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, + notificationsEnabled: notificationsEnabled) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: RequestIdentifier.registerToken) + } + @discardableResult func disableDeviceForCurrentUser(hexToken: String, identitySnapshot: UserIdentitySnapshot?, diff --git a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift index ba3c61ee6..038332255 100644 --- a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift @@ -22,18 +22,18 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { dateProvider: dateProvider) } + @discardableResult func register(registerTokenInfo: RegisterTokenInfo, - notificationStateProvider: NotificationStateProviderProtocol, + notificationsEnabled: Bool, onSuccess: OnSuccessHandler? = nil, - onFailure: OnFailureHandler? = nil) { - notificationStateProvider.isNotificationsEnabled { enabled in - self.register(registerTokenInfo: registerTokenInfo, - notificationsEnabled: enabled, - onSuccess: onSuccess, - onFailure: onFailure) - } + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.register(registerTokenInfo: registerTokenInfo, + notificationsEnabled: notificationsEnabled) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: RequestIdentifier.registerToken) } - + @discardableResult func disableDeviceForCurrentUser(hexToken: String, identitySnapshot: UserIdentitySnapshot?, @@ -309,18 +309,6 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { private let apiClient: ApiClientProtocol private weak var authManager: IterableAuthManagerProtocol? - @discardableResult - private func register(registerTokenInfo: RegisterTokenInfo, - notificationsEnabled: Bool, - onSuccess: OnSuccessHandler? = nil, - onFailure: OnFailureHandler? = nil) -> Pending { - sendRequest(requestProvider: { apiClient.register(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled) }, - successHandler: onSuccess, - failureHandler: onFailure, - requestIdentifier: "registerToken") - } - @discardableResult private func disableDevice(forAllUsers allUsers: Bool, hexToken: String, diff --git a/swift-sdk/Internal/api-client/Request/RequestHandler.swift b/swift-sdk/Internal/api-client/Request/RequestHandler.swift index 166ea6d54..91dffc74e 100644 --- a/swift-sdk/Internal/api-client/Request/RequestHandler.swift +++ b/swift-sdk/Internal/api-client/Request/RequestHandler.swift @@ -48,10 +48,14 @@ class RequestHandler: RequestHandlerProtocol { notificationStateProvider: NotificationStateProviderProtocol, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - onlineProcessor.register(registerTokenInfo: registerTokenInfo, - notificationStateProvider: notificationStateProvider, - onSuccess: onSuccess, - onFailure: onFailure) + notificationStateProvider.isNotificationsEnabled { notificationsEnabled in + _ = self.sendUsingRequestProcessor { processor in + processor.register(registerTokenInfo: registerTokenInfo, + notificationsEnabled: notificationsEnabled, + onSuccess: onSuccess, + onFailure: onFailure) + } + } } @discardableResult diff --git a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift index c954fbaaf..772ac73c5 100644 --- a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift +++ b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift @@ -27,10 +27,17 @@ struct UpdateSubscriptionsInfo { enum RequestIdentifier { static let disableDevice = "disableDevice" + static let registerToken = "registerToken" } /// `RequestHandler` will delegate network related calls to this protocol. protocol RequestProcessorProtocol { + @discardableResult + func register(registerTokenInfo: RegisterTokenInfo, + notificationsEnabled: Bool, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func disableDeviceForCurrentUser(hexToken: String, identitySnapshot: UserIdentitySnapshot?, diff --git a/tests/offline-events-tests/RequestHandlerTests.swift b/tests/offline-events-tests/RequestHandlerTests.swift index 1114bb258..af9bd524a 100644 --- a/tests/offline-events-tests/RequestHandlerTests.swift +++ b/tests/offline-events-tests/RequestHandlerTests.swift @@ -97,6 +97,86 @@ class RequestHandlerTests: XCTestCase { wait(for: [expectation1], timeout: testExpectationTimeout) } + + func testOfflineRegisterTokenReplaysWithExpectedBody() throws { + let registerTokenInfo = createRegisterTokenInfo() + let bodyValidated = expectation(description: #function) + let networkSession = MockNetworkSession() + networkSession.requestCallback = { request in + self.validateRegisterTokenRequest(request, + registerTokenInfo: registerTokenInfo, + notificationsEnabled: true) + bodyValidated.fulfill() + } + + let requestHandler = createRequestHandler(networkSession: networkSession, + notificationCenter: MockNotificationCenter(), + selectOffline: true) + + requestHandler.register(registerTokenInfo: registerTokenInfo, + notificationStateProvider: MockNotificationStateProvider(enabled: true), + onSuccess: nil, + onFailure: nil) + + waitForTaskRunner(requestHandler: requestHandler, expectation: bodyValidated) + } + + func testOfflineRegisterTokenRetriesAfterNetworkFailureWithSnapshottedNotificationsEnabled() throws { + let registerTokenInfo = createRegisterTokenInfo() + let notificationCenter = MockNotificationCenter() + let notificationStateProvider = MockNotificationStateProvider(enabled: true) + let firstRetryObserved = expectation(description: "register task retained after transient failure") + let replayedRequestObserved = expectation(description: "register task replayed") + let successObserved = expectation(description: "register success") + let networkError = IterableError.general(description: "The Internet connection appears to be offline.") + + let retryCallback = notificationCenter.addCallback(forNotification: .iterableTaskFinishedWithRetry) { _ in + firstRetryObserved.fulfill() + } + XCTAssertNotNil(retryCallback) + + var registerAttemptCount = 0 + let networkSession = MockNetworkSession(responseCallback: { _ in + registerAttemptCount += 1 + if registerAttemptCount == 1 { + return MockNetworkSession.MockResponse(statusCode: 0, + data: nil, + error: networkError) + } + + return MockNetworkSession.MockResponse() + }) + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.registerDeviceToken) == true else { + return + } + + self.validateRegisterTokenRequest(request, + registerTokenInfo: registerTokenInfo, + notificationsEnabled: true) + if registerAttemptCount == 2 { + replayedRequestObserved.fulfill() + } + } + + let requestHandler = createRequestHandler(networkSession: networkSession, + notificationCenter: notificationCenter, + selectOffline: true) + requestHandler.start() + requestHandler.register(registerTokenInfo: registerTokenInfo, + notificationStateProvider: notificationStateProvider, + onSuccess: { _ in successObserved.fulfill() }, + onFailure: { _, _ in XCTFail("register should retry and eventually succeed") }) + notificationStateProvider.enabled = false + + wait(for: [firstRetryObserved], timeout: testExpectationTimeout) + XCTAssertEqual(try persistenceContextProvider.mainQueueContext().findAllTasks().count, 1) + + wait(for: [replayedRequestObserved, successObserved], timeout: testExpectationTimeout) + XCTAssertEqual(registerAttemptCount, 2) + XCTAssertEqual(try persistenceContextProvider.mainQueueContext().findAllTasks().count, 0) + requestHandler.stop() + } func testDisableUserforCurrentUser() throws { let hexToken = "zee-token" @@ -1438,6 +1518,70 @@ class RequestHandlerTests: XCTestCase { wait(for: [expectation], timeout: testExpectationTimeout) requestHandler.stop() } + + private func createRegisterTokenInfo() -> RegisterTokenInfo { + RegisterTokenInfo(hexToken: "zee-token", + appName: "zee-app-name", + pushServicePlatform: .auto, + apnsType: .sandbox, + deviceId: "deviceId", + deviceAttributes: [:], + sdkVersion: "6.x.x", + mobileFrameworkInfo: IterableAPIMobileFrameworkInfo(frameworkType: .native, + iterableSdkVersion: "6.x.x")) + } + + private func validateRegisterTokenRequest(_ request: URLRequest, + registerTokenInfo: RegisterTokenInfo, + notificationsEnabled: Bool, + file: StaticString = #filePath, + line: UInt = #line) { + TestUtils.validate(request: request, + apiEndPoint: Endpoint.api, + path: Const.Path.registerDeviceToken) + + let requestBody = request.bodyDict + XCTAssertEqual(requestBody[JsonKey.email] as? String, + "user@example.com", + file: file, + line: line) + + guard let device = requestBody[JsonKey.device] as? [String: Any] else { + XCTFail("missing device dictionary", file: file, line: line) + return + } + + XCTAssertEqual(device[JsonKey.token] as? String, + registerTokenInfo.hexToken, + file: file, + line: line) + XCTAssertEqual(device[JsonKey.applicationName] as? String, + registerTokenInfo.appName, + file: file, + line: line) + XCTAssertEqual(device[JsonKey.platform] as? String, + "APNS_SANDBOX", + file: file, + line: line) + + guard let dataFields = device[JsonKey.dataFields] as? [String: Any] else { + XCTFail("missing dataFields dictionary", file: file, line: line) + return + } + + XCTAssertEqual(dataFields[JsonKey.deviceId] as? String, + registerTokenInfo.deviceId, + file: file, + line: line) + XCTAssertEqual(dataFields[JsonKey.iterableSdkVersion] as? String, + registerTokenInfo.sdkVersion, + file: file, + line: line) + XCTAssertEqual(dataFields[JsonKey.notificationsEnabled] as? Bool, + notificationsEnabled, + file: file, + line: line) + } struct Exp { let successExpectation: XCTestExpectation From fb361ff4a791adff885f35a7c25be330fefa8ed0 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 1 Jun 2026 12:33:58 +0100 Subject: [PATCH 2/3] SDK-108 Snapshot identity at register call time Address review: register now captures a UserIdentitySnapshot synchronously before the async notification-state callback and deferred processor selection, mirroring disableDeviceForCurrentUser. This ensures the register request targets the user who was current at call time rather than whoever live auth points to when the deferred/offline build resolves. The snapshot threads through both the online and offline paths into RequestCreator.createRegisterTokenRequest, which applies it (and derives preferUserId from the .userId case) instead of reading live auth. Callers that pass no snapshot keep the previous live-auth behavior and the auth-missing guard. Tests: register at call time then mutate/clear identity before the deferred callback resolves; assert both offline-replay and online-fallback requests still carry the call-time identity and preferUserId. Co-Authored-By: Claude Opus 4.7 (1M context) --- swift-sdk/Internal/api-client/ApiClient.swift | 7 +- .../api-client/ApiClientProtocol.swift | 4 +- .../Request/OfflineRequestProcessor.swift | 4 +- .../Request/OnlineRequestProcessor.swift | 4 +- .../api-client/Request/RequestCreator.swift | 20 +++- .../api-client/Request/RequestHandler.swift | 10 ++ .../Request/RequestProcessorProtocol.swift | 1 + .../RequestHandlerTests.swift | 112 +++++++++++++++++- tests/unit-tests/BlankApiClient.swift | 4 +- 9 files changed, 148 insertions(+), 18 deletions(-) diff --git a/swift-sdk/Internal/api-client/ApiClient.swift b/swift-sdk/Internal/api-client/ApiClient.swift index 35593a159..9308c31c9 100644 --- a/swift-sdk/Internal/api-client/ApiClient.swift +++ b/swift-sdk/Internal/api-client/ApiClient.swift @@ -128,9 +128,12 @@ class ApiClient { extension ApiClient: ApiClientProtocol { - func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { + func register(registerTokenInfo: RegisterTokenInfo, + notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot?) -> Pending { let result = createRequestCreator().flatMap { $0.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled) } + notificationsEnabled: notificationsEnabled, + identitySnapshot: identitySnapshot) } return send(iterableRequestResult: result) } diff --git a/swift-sdk/Internal/api-client/ApiClientProtocol.swift b/swift-sdk/Internal/api-client/ApiClientProtocol.swift index 97cadbc10..228e75eeb 100644 --- a/swift-sdk/Internal/api-client/ApiClientProtocol.swift +++ b/swift-sdk/Internal/api-client/ApiClientProtocol.swift @@ -5,7 +5,9 @@ import Foundation protocol ApiClientProtocol: AnyObject { - func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending + func register(registerTokenInfo: RegisterTokenInfo, + notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot?) -> Pending func updateUser(_ dataFields: [AnyHashable: Any], mergeNestedObjects: Bool) -> Pending diff --git a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift index ba473e2ac..0d3a48f43 100644 --- a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift @@ -44,11 +44,13 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { @discardableResult func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending { let requestGenerator = { (requestCreator: RequestCreator) in requestCreator.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled) + notificationsEnabled: notificationsEnabled, + identitySnapshot: identitySnapshot) } return sendIterableRequest(requestGenerator: requestGenerator, diff --git a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift index 038332255..932b5a204 100644 --- a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift @@ -25,10 +25,12 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { @discardableResult func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot?, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { sendRequest(requestProvider: { apiClient.register(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled) }, + notificationsEnabled: notificationsEnabled, + identitySnapshot: identitySnapshot) }, successHandler: onSuccess, failureHandler: onFailure, requestIdentifier: RequestIdentifier.registerToken) diff --git a/swift-sdk/Internal/api-client/Request/RequestCreator.swift b/swift-sdk/Internal/api-client/Request/RequestCreator.swift index e1b11652e..2616d05a5 100644 --- a/swift-sdk/Internal/api-client/Request/RequestCreator.swift +++ b/swift-sdk/Internal/api-client/Request/RequestCreator.swift @@ -34,8 +34,9 @@ struct RequestCreator { } func createRegisterTokenRequest(registerTokenInfo: RegisterTokenInfo, - notificationsEnabled: Bool) -> Result { - if case .none = auth.emailOrUserId { + notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot? = nil) -> Result { + if identitySnapshot == nil, case .none = auth.emailOrUserId { ITBError(Self.authMissingMessage) return .failure(IterableError.general(description: Self.authMissingMessage)) } @@ -60,10 +61,17 @@ struct RequestCreator { body[JsonKey.device] = deviceDictionary - setCurrentUser(inDict: &body) - - if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { - body[JsonKey.preferUserId] = true + if let identitySnapshot { + identitySnapshot.apply(to: &body) + if case .userId = identitySnapshot { + body[JsonKey.preferUserId] = true + } + } else { + setCurrentUser(inDict: &body) + + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { + body[JsonKey.preferUserId] = true + } } return .success(.post(createPostRequest(path: Const.Path.registerDeviceToken, body: body))) diff --git a/swift-sdk/Internal/api-client/Request/RequestHandler.swift b/swift-sdk/Internal/api-client/Request/RequestHandler.swift index 91dffc74e..01c82aacc 100644 --- a/swift-sdk/Internal/api-client/Request/RequestHandler.swift +++ b/swift-sdk/Internal/api-client/Request/RequestHandler.swift @@ -48,10 +48,20 @@ class RequestHandler: RequestHandlerProtocol { notificationStateProvider: NotificationStateProviderProtocol, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + // Snapshot identity before the async notification-state callback so deferred + // request construction still targets the call-time user. + guard let identitySnapshot = UserIdentitySnapshot(auth: authProvider?.auth) else { + let reason = "register(token:) called without a current user identity" + ITBError(reason) + onFailure?(reason, nil) + return + } + notificationStateProvider.isNotificationsEnabled { notificationsEnabled in _ = self.sendUsingRequestProcessor { processor in processor.register(registerTokenInfo: registerTokenInfo, notificationsEnabled: notificationsEnabled, + identitySnapshot: identitySnapshot, onSuccess: onSuccess, onFailure: onFailure) } diff --git a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift index 772ac73c5..976bd303a 100644 --- a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift +++ b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift @@ -35,6 +35,7 @@ protocol RequestProcessorProtocol { @discardableResult func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending diff --git a/tests/offline-events-tests/RequestHandlerTests.swift b/tests/offline-events-tests/RequestHandlerTests.swift index af9bd524a..955e22782 100644 --- a/tests/offline-events-tests/RequestHandlerTests.swift +++ b/tests/offline-events-tests/RequestHandlerTests.swift @@ -177,6 +177,88 @@ class RequestHandlerTests: XCTestCase { XCTAssertEqual(try persistenceContextProvider.mainQueueContext().findAllTasks().count, 0) requestHandler.stop() } + + func testOfflineRegisterTokenCapturesIdentityAtCallTime() throws { + let registerTokenInfo = createRegisterTokenInfo() + let originalUserId = "original-user-id" + let liveAuth = MutableAuthProvider( + initial: Auth(userId: originalUserId, email: nil, authToken: nil, userIdUnknownUser: nil) + ) + let notificationStateProvider = DeferredNotificationStateProvider() + let bodyValidated = expectation(description: #function) + + let networkSession = MockNetworkSession() + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.registerDeviceToken) == true else { + return + } + + self.validateRegisterTokenRequest(request, + registerTokenInfo: registerTokenInfo, + notificationsEnabled: true, + expectedEmail: nil, + expectedUserId: originalUserId, + expectedPreferUserId: true) + bodyValidated.fulfill() + } + + let requestHandler = createRequestHandler(networkSession: networkSession, + notificationCenter: MockNotificationCenter(), + selectOffline: true, + authProvider: liveAuth) + + requestHandler.register(registerTokenInfo: registerTokenInfo, + notificationStateProvider: notificationStateProvider, + onSuccess: nil, + onFailure: { reason, _ in XCTFail("register should not fail: \(reason ?? "nil")") }) + + liveAuth.currentAuth = Auth(userId: nil, email: nil, authToken: nil, userIdUnknownUser: nil) + notificationStateProvider.resolve(enabled: true) + + waitForTaskRunner(requestHandler: requestHandler, expectation: bodyValidated) + } + + func testOnlineFallbackRegisterTokenCapturesIdentityAtCallTime() throws { + let registerTokenInfo = createRegisterTokenInfo() + let originalEmail = "original@example.com" + let mutatedUserId = "mutated-user-id" + let liveAuth = MutableAuthProvider( + initial: Auth(userId: nil, email: originalEmail, authToken: nil, userIdUnknownUser: nil) + ) + let notificationStateProvider = DeferredNotificationStateProvider() + let bodyValidated = expectation(description: #function) + + let networkSession = MockNetworkSession() + networkSession.requestCallback = { request in + guard request.url?.absoluteString.contains(Const.Path.registerDeviceToken) == true else { + return + } + + self.validateRegisterTokenRequest(request, + registerTokenInfo: registerTokenInfo, + notificationsEnabled: true, + expectedEmail: originalEmail, + expectedUserId: nil, + expectedPreferUserId: nil) + bodyValidated.fulfill() + } + + let requestHandler = createRequestHandler(networkSession: networkSession, + notificationCenter: MockNotificationCenter(), + selectOffline: true, + authProvider: liveAuth, + maxTasks: 0) + + requestHandler.register(registerTokenInfo: registerTokenInfo, + notificationStateProvider: notificationStateProvider, + onSuccess: nil, + onFailure: { reason, _ in XCTFail("register should not fail: \(reason ?? "nil")") }) + + liveAuth.currentAuth = Auth(userId: mutatedUserId, email: nil, authToken: nil, userIdUnknownUser: nil) + notificationStateProvider.resolve(enabled: true) + + wait(for: [bodyValidated], timeout: testExpectationTimeout) + } func testDisableUserforCurrentUser() throws { let hexToken = "zee-token" @@ -1435,9 +1517,10 @@ class RequestHandlerTests: XCTestCase { private func createRequestHandler(networkSession: NetworkSessionProtocol, notificationCenter: NotificationCenterProtocol, selectOffline: Bool, - authProvider: AuthProvider? = nil) -> RequestHandlerProtocol { + authProvider: AuthProvider? = nil, + maxTasks: Int = 1000) -> RequestHandlerProtocol { let resolvedAuthProvider: AuthProvider = authProvider ?? self - let healthMonitor = HealthMonitor(dataProvider: HealthMonitorDataProvider(maxTasks: 1000, + let healthMonitor = HealthMonitor(dataProvider: HealthMonitorDataProvider(maxTasks: maxTasks, persistenceContextProvider: persistenceContextProvider), dateProvider: dateProvider, networkSession: networkSession) @@ -1534,6 +1617,9 @@ class RequestHandlerTests: XCTestCase { private func validateRegisterTokenRequest(_ request: URLRequest, registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, + expectedEmail: String? = "user@example.com", + expectedUserId: String? = nil, + expectedPreferUserId: Bool? = nil, file: StaticString = #filePath, line: UInt = #line) { TestUtils.validate(request: request, @@ -1541,10 +1627,9 @@ class RequestHandlerTests: XCTestCase { path: Const.Path.registerDeviceToken) let requestBody = request.bodyDict - XCTAssertEqual(requestBody[JsonKey.email] as? String, - "user@example.com", - file: file, - line: line) + XCTAssertEqual(requestBody[JsonKey.email] as? String, expectedEmail, file: file, line: line) + XCTAssertEqual(requestBody[JsonKey.userId] as? String, expectedUserId, file: file, line: line) + XCTAssertEqual(requestBody[JsonKey.preferUserId] as? Bool, expectedPreferUserId, file: file, line: line) guard let device = requestBody[JsonKey.device] as? [String: Any] else { XCTFail("missing device dictionary", file: file, line: line) @@ -1695,3 +1780,18 @@ fileprivate final class MutableAuthProvider: AuthProvider { init(initial: Auth) { currentAuth = initial } var auth: Auth { currentAuth } } + +fileprivate final class DeferredNotificationStateProvider: NotificationStateProviderProtocol { + private var callback: ((Bool) -> Void)? + + func isNotificationsEnabled(withCallback callback: @escaping (Bool) -> Void) { + self.callback = callback + } + + func registerForRemoteNotifications() {} + + func resolve(enabled: Bool) { + callback?(enabled) + callback = nil + } +} diff --git a/tests/unit-tests/BlankApiClient.swift b/tests/unit-tests/BlankApiClient.swift index 14b377b87..fabcfe4a5 100644 --- a/tests/unit-tests/BlankApiClient.swift +++ b/tests/unit-tests/BlankApiClient.swift @@ -42,7 +42,9 @@ class BlankApiClient: ApiClientProtocol { } - func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { + func register(registerTokenInfo: RegisterTokenInfo, + notificationsEnabled: Bool, + identitySnapshot: UserIdentitySnapshot?) -> Pending { Pending() } From eed546e2471480d3d055542b5165a2c0b6aa5c84 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Tue, 2 Jun 2026 11:32:25 +0100 Subject: [PATCH 3/3] SDK-108 Simplify register identity capture per review Drop the cross-layer UserIdentitySnapshot threading. Capture call-time auth once in RequestHandler.register and carry it on RegisterTokenInfo.auth; RequestCreator resolves registerTokenInfo.auth ?? self.auth through the existing single body path. Reverts the snapshot parameter on ApiClient, the processors, and the protocol. Register offline tasks are purged on logout (only disableDevice is preserved), so the full disableDevice snapshot symmetry was unnecessary. Keep an upfront identity guard in register: dropping it would regress onFailure on the offline no-user path, since the callback only attaches after a task is scheduled. Build green; offline RequestHandlerTests 36/0. Co-Authored-By: Claude Opus 4.7 (1M context) --- swift-sdk/Internal/api-client/ApiClient.swift | 7 ++---- .../api-client/ApiClientProtocol.swift | 4 +--- .../Request/OfflineRequestProcessor.swift | 4 +--- .../Request/OnlineRequestProcessor.swift | 4 +--- .../api-client/Request/RequestCreator.swift | 23 ++++++++----------- .../api-client/Request/RequestHandler.swift | 16 +++++++++---- .../Request/RequestProcessorProtocol.swift | 2 +- tests/unit-tests/BlankApiClient.swift | 4 +--- 8 files changed, 28 insertions(+), 36 deletions(-) diff --git a/swift-sdk/Internal/api-client/ApiClient.swift b/swift-sdk/Internal/api-client/ApiClient.swift index 9308c31c9..35593a159 100644 --- a/swift-sdk/Internal/api-client/ApiClient.swift +++ b/swift-sdk/Internal/api-client/ApiClient.swift @@ -128,12 +128,9 @@ class ApiClient { extension ApiClient: ApiClientProtocol { - func register(registerTokenInfo: RegisterTokenInfo, - notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot?) -> Pending { + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { let result = createRequestCreator().flatMap { $0.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled, - identitySnapshot: identitySnapshot) } + notificationsEnabled: notificationsEnabled) } return send(iterableRequestResult: result) } diff --git a/swift-sdk/Internal/api-client/ApiClientProtocol.swift b/swift-sdk/Internal/api-client/ApiClientProtocol.swift index 228e75eeb..97cadbc10 100644 --- a/swift-sdk/Internal/api-client/ApiClientProtocol.swift +++ b/swift-sdk/Internal/api-client/ApiClientProtocol.swift @@ -5,9 +5,7 @@ import Foundation protocol ApiClientProtocol: AnyObject { - func register(registerTokenInfo: RegisterTokenInfo, - notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot?) -> Pending + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending func updateUser(_ dataFields: [AnyHashable: Any], mergeNestedObjects: Bool) -> Pending diff --git a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift index 0d3a48f43..ba473e2ac 100644 --- a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift @@ -44,13 +44,11 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { @discardableResult func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending { let requestGenerator = { (requestCreator: RequestCreator) in requestCreator.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled, - identitySnapshot: identitySnapshot) + notificationsEnabled: notificationsEnabled) } return sendIterableRequest(requestGenerator: requestGenerator, diff --git a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift index 932b5a204..038332255 100644 --- a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift @@ -25,12 +25,10 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { @discardableResult func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot?, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { sendRequest(requestProvider: { apiClient.register(registerTokenInfo: registerTokenInfo, - notificationsEnabled: notificationsEnabled, - identitySnapshot: identitySnapshot) }, + notificationsEnabled: notificationsEnabled) }, successHandler: onSuccess, failureHandler: onFailure, requestIdentifier: RequestIdentifier.registerToken) diff --git a/swift-sdk/Internal/api-client/Request/RequestCreator.swift b/swift-sdk/Internal/api-client/Request/RequestCreator.swift index 2616d05a5..5b817a4a6 100644 --- a/swift-sdk/Internal/api-client/Request/RequestCreator.swift +++ b/swift-sdk/Internal/api-client/Request/RequestCreator.swift @@ -34,9 +34,9 @@ struct RequestCreator { } func createRegisterTokenRequest(registerTokenInfo: RegisterTokenInfo, - notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot? = nil) -> Result { - if identitySnapshot == nil, case .none = auth.emailOrUserId { + notificationsEnabled: Bool) -> Result { + let auth = registerTokenInfo.auth ?? self.auth + if case .none = auth.emailOrUserId { ITBError(Self.authMissingMessage) return .failure(IterableError.general(description: Self.authMissingMessage)) } @@ -61,17 +61,10 @@ struct RequestCreator { body[JsonKey.device] = deviceDictionary - if let identitySnapshot { - identitySnapshot.apply(to: &body) - if case .userId = identitySnapshot { - body[JsonKey.preferUserId] = true - } - } else { - setCurrentUser(inDict: &body) + setCurrentUser(auth: auth, inDict: &body) - if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { - body[JsonKey.preferUserId] = true - } + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { + body[JsonKey.preferUserId] = true } return .success(.post(createPostRequest(path: Const.Path.registerDeviceToken, body: body))) @@ -745,6 +738,10 @@ struct RequestCreator { } private func setCurrentUser(inDict dict: inout [AnyHashable: Any]) { + setCurrentUser(auth: auth, inDict: &dict) + } + + private func setCurrentUser(auth: Auth, inDict dict: inout [AnyHashable: Any]) { switch auth.emailOrUserId { case let .email(email): dict.setValue(for: JsonKey.email, value: email) diff --git a/swift-sdk/Internal/api-client/Request/RequestHandler.swift b/swift-sdk/Internal/api-client/Request/RequestHandler.swift index 01c82aacc..4fbaec972 100644 --- a/swift-sdk/Internal/api-client/Request/RequestHandler.swift +++ b/swift-sdk/Internal/api-client/Request/RequestHandler.swift @@ -50,18 +50,24 @@ class RequestHandler: RequestHandlerProtocol { onFailure: OnFailureHandler?) { // Snapshot identity before the async notification-state callback so deferred // request construction still targets the call-time user. - guard let identitySnapshot = UserIdentitySnapshot(auth: authProvider?.auth) else { - let reason = "register(token:) called without a current user identity" - ITBError(reason) - onFailure?(reason, nil) + let missingIdentityReason = "register(token:) called without a current user identity" + guard let auth = authProvider?.auth else { + ITBError(missingIdentityReason) + onFailure?(missingIdentityReason, nil) + return + } + if case .none = auth.emailOrUserId { + ITBError(missingIdentityReason) + onFailure?(missingIdentityReason, nil) return } + var registerTokenInfo = registerTokenInfo + registerTokenInfo.auth = auth notificationStateProvider.isNotificationsEnabled { notificationsEnabled in _ = self.sendUsingRequestProcessor { processor in processor.register(registerTokenInfo: registerTokenInfo, notificationsEnabled: notificationsEnabled, - identitySnapshot: identitySnapshot, onSuccess: onSuccess, onFailure: onFailure) } diff --git a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift index 976bd303a..0d148fb6c 100644 --- a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift +++ b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift @@ -14,6 +14,7 @@ struct RegisterTokenInfo { let deviceAttributes: [String: String] let sdkVersion: String? let mobileFrameworkInfo: IterableAPIMobileFrameworkInfo + var auth: Auth? = nil } struct UpdateSubscriptionsInfo { @@ -35,7 +36,6 @@ protocol RequestProcessorProtocol { @discardableResult func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending diff --git a/tests/unit-tests/BlankApiClient.swift b/tests/unit-tests/BlankApiClient.swift index fabcfe4a5..14b377b87 100644 --- a/tests/unit-tests/BlankApiClient.swift +++ b/tests/unit-tests/BlankApiClient.swift @@ -42,9 +42,7 @@ class BlankApiClient: ApiClientProtocol { } - func register(registerTokenInfo: RegisterTokenInfo, - notificationsEnabled: Bool, - identitySnapshot: UserIdentitySnapshot?) -> Pending { + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { Pending() }