From 2e21d399752f95a1f28e80f2bf28712f8b22d64a Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Fri, 22 May 2026 09:55:26 +0100 Subject: [PATCH] SDK-99 Add async/await variants for disablePush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Swift concurrency overloads for the disablePush APIs: - disableDeviceForCurrentUser() async throws - disableDeviceForAllUsers() async throws Both are availability-gated (@available iOS 13 / macOS 10.15 / tvOS 13 / watchOS 6) and @nonobjc, so the existing parameterless and completion-handler methods — and the Obj-C surface — are unchanged. Each overload wraps the existing completion-handler API via withCheckedThrowingContinuation, throwing the public SendRequestError on failure (carrying the reason/data from OnFailureHandler). A small NSLock-backed resume-once guard prevents double-resume, and an explicit preflight throws "Iterable SDK is not initialized" rather than creating a continuation that would hang on the existing silent not-initialized path. The disablePush offline-retry user story shipped separately under SDK-297, and registerDevice retry is tracked in SDK-108; this ticket was rescoped to the async/await wrappers only. Tests cover success (current + all users), failure throwing SendRequestError with the right reason/data, and the not-initialized throw (verifying it does not hang). Co-Authored-By: Claude Opus 4.7 (1M context) --- swift-sdk/SDK/IterableAPI.swift | 61 ++++++++++ tests/unit-tests/IterableAPITests.swift | 146 ++++++++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index 8274183ce..226814500 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -300,11 +300,29 @@ import UIKit public static func disableDeviceForCurrentUser() { disableDeviceForCurrentUser(withOnSuccess: nil, onFailure: nil) } + + /// Disable this device's token in Iterable, for the current user. + @nonobjc + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) + public static func disableDeviceForCurrentUser() async throws { + try await disableDeviceAsync { onSuccess, onFailure in + disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) + } + } /// Disable this device's token in Iterable, for all users on this device. public static func disableDeviceForAllUsers() { disableDeviceForAllUsers(withOnSuccess: nil, onFailure: nil) } + + /// Disable this device's token in Iterable, for all users on this device. + @nonobjc + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) + public static func disableDeviceForAllUsers() async throws { + try await disableDeviceAsync { onSuccess, onFailure in + disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure) + } + } /// Disable this device's token in Iterable, for the current user, with custom completion blocks /// @@ -331,6 +349,29 @@ import UIKit implementation.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure) } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) + private static func disableDeviceAsync( + _ disableDevice: (_ onSuccess: OnSuccessHandler?, _ onFailure: OnFailureHandler?) -> Void + ) async throws { + guard let implementation, implementation.isSDKInitialized() else { + throw SendRequestError(reason: sdkNotInitializedErrorReason) + } + + try await withCheckedThrowingContinuation { continuation in + let resumeGuard = AsyncContinuationResumeGuard() + + disableDevice({ _ in + resumeGuard.resume { + continuation.resume(returning: ()) + } + }, { reason, data in + resumeGuard.resume { + continuation.resume(throwing: SendRequestError(reason: reason, data: data)) + } + }) + } + } /// Updates the available user fields /// @@ -865,7 +906,27 @@ import UIKit // MARK: - Private/Internal + private static let sdkNotInitializedErrorReason = "Iterable SDK is not initialized" + static var implementation: InternalIterableAPI? override private init() { super.init() } } + +private final class AsyncContinuationResumeGuard { + private let lock = NSLock() + private var didResume = false + + func resume(_ block: () -> Void) { + lock.lock() + let shouldResume = !didResume + if shouldResume { + didResume = true + } + lock.unlock() + + if shouldResume { + block() + } + } +} diff --git a/tests/unit-tests/IterableAPITests.swift b/tests/unit-tests/IterableAPITests.swift index d42fe9142..376fcbc55 100644 --- a/tests/unit-tests/IterableAPITests.swift +++ b/tests/unit-tests/IterableAPITests.swift @@ -681,6 +681,102 @@ class IterableAPITests: XCTestCase { wait(for: [expectation], timeout: testExpectationTimeout) } + + @available(iOS 13.0, *) + func testDisableDeviceForCurrentUserAsyncSuccess() async throws { + let oldImplementation = IterableAPI.implementation + defer { IterableAPI.implementation = oldImplementation } + + let networkSession = MockNetworkSession(statusCode: 200) + let token = try await setUpIterableAPIForAsyncDisableDevice(networkSession: networkSession) + + try await IterableAPI.disableDeviceForCurrentUser() + + guard let request = networkSession.getRequest(withEndPoint: Const.Path.disableDevice) else { + return XCTFail("Expected disableDevice request") + } + guard let body = TestUtils.getRequestBody(request: request) else { + return XCTFail("Expected disableDevice request body") + } + + TestUtils.validate(request: request, + requestType: .post, + apiEndPoint: Endpoint.api, + path: Const.Path.disableDevice, + queryParams: []) + TestUtils.validateElementPresent(withName: JsonKey.token, andValue: token.hexString(), inDictionary: body) + TestUtils.validateElementPresent(withName: JsonKey.email, andValue: "user@example.com", inDictionary: body) + } + + @available(iOS 13.0, *) + func testDisableDeviceForAllUsersAsyncSuccess() async throws { + let oldImplementation = IterableAPI.implementation + defer { IterableAPI.implementation = oldImplementation } + + let networkSession = MockNetworkSession(statusCode: 200) + let token = try await setUpIterableAPIForAsyncDisableDevice(networkSession: networkSession) + + try await IterableAPI.disableDeviceForAllUsers() + + guard let request = networkSession.getRequest(withEndPoint: Const.Path.disableDevice) else { + return XCTFail("Expected disableDevice request") + } + guard let body = TestUtils.getRequestBody(request: request) else { + return XCTFail("Expected disableDevice request body") + } + + TestUtils.validate(request: request, + requestType: .post, + apiEndPoint: Endpoint.api, + path: Const.Path.disableDevice, + queryParams: []) + TestUtils.validateElementPresent(withName: JsonKey.token, andValue: token.hexString(), inDictionary: body) + TestUtils.validateElementNotPresent(withName: JsonKey.email, inDictionary: body) + TestUtils.validateElementNotPresent(withName: JsonKey.userId, inDictionary: body) + } + + @available(iOS 13.0, *) + func testDisableDeviceForCurrentUserAsyncFailureThrowsSendRequestError() async throws { + let oldImplementation = IterableAPI.implementation + defer { IterableAPI.implementation = oldImplementation } + + let failureReason = "disable failed" + let networkSession = MockNetworkSession(responseCallback: { url in + if url.absoluteString.contains(Const.Path.disableDevice) { + return MockNetworkSession.MockResponse(statusCode: 400, + data: ["msg": failureReason].toJsonData()) + } + return MockNetworkSession.MockResponse(statusCode: 200) + }) + _ = try await setUpIterableAPIForAsyncDisableDevice(networkSession: networkSession) + + do { + try await IterableAPI.disableDeviceForCurrentUser() + XCTFail("Expected disableDeviceForCurrentUser async API to throw") + } catch let error as SendRequestError { + XCTAssertEqual(error.reason, failureReason) + XCTAssertEqual(error.data, ["msg": failureReason].toJsonData()) + } catch { + XCTFail("Expected SendRequestError, got \(error)") + } + } + + @available(iOS 13.0, *) + func testDisableDeviceForCurrentUserAsyncNotInitializedThrowsSendRequestError() async { + let oldImplementation = IterableAPI.implementation + IterableAPI.implementation = nil + defer { IterableAPI.implementation = oldImplementation } + + do { + try await IterableAPI.disableDeviceForCurrentUser() + XCTFail("Expected disableDeviceForCurrentUser async API to throw") + } catch let error as SendRequestError { + XCTAssertEqual(error.reason, "Iterable SDK is not initialized") + XCTAssertNil(error.data) + } catch { + XCTFail("Expected SendRequestError, got \(error)") + } + } func testUpdateCart() { let condition1 = XCTestExpectation(description: #function) @@ -1478,4 +1574,54 @@ class IterableAPITests: XCTestCase { XCTAssertEqual(dateFromMilliseconds, testDate) } + @available(iOS 13.0, *) + private func setUpIterableAPIForAsyncDisableDevice(networkSession: MockNetworkSession) async throws -> Data { + let config = IterableConfig() + config.pushIntegrationName = "my-push-integration" + + IterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey, + config: config, + networkSession: networkSession) + IterableAPI.email = "user@example.com" + + let token = "zeeToken".data(using: .utf8)! + try await registerTokenForAsyncDisableDevice(token) + return token + } + + @available(iOS 13.0, *) + private func registerTokenForAsyncDisableDevice(_ token: Data) async throws { + try await withCheckedThrowingContinuation { continuation in + let resumeGuard = TestAsyncContinuationResumeGuard() + + IterableAPI.register(token: token, onSuccess: { _ in + resumeGuard.resume { + continuation.resume(returning: ()) + } + }, onFailure: { reason, data in + resumeGuard.resume { + continuation.resume(throwing: SendRequestError(reason: reason, data: data)) + } + }) + } + } + +} + +private final class TestAsyncContinuationResumeGuard { + private let lock = NSLock() + private var didResume = false + + func resume(_ block: () -> Void) { + lock.lock() + let shouldResume = !didResume + if shouldResume { + didResume = true + } + lock.unlock() + + if shouldResume { + block() + } + } }