Skip to content

Commit 2e21d39

Browse files
sumeruchatclaude
authored andcommitted
SDK-99 Add async/await variants for disablePush
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) <noreply@anthropic.com>
1 parent 7eb259c commit 2e21d39

2 files changed

Lines changed: 207 additions & 0 deletions

File tree

swift-sdk/SDK/IterableAPI.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,11 +300,29 @@ import UIKit
300300
public static func disableDeviceForCurrentUser() {
301301
disableDeviceForCurrentUser(withOnSuccess: nil, onFailure: nil)
302302
}
303+
304+
/// Disable this device's token in Iterable, for the current user.
305+
@nonobjc
306+
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
307+
public static func disableDeviceForCurrentUser() async throws {
308+
try await disableDeviceAsync { onSuccess, onFailure in
309+
disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure)
310+
}
311+
}
303312

304313
/// Disable this device's token in Iterable, for all users on this device.
305314
public static func disableDeviceForAllUsers() {
306315
disableDeviceForAllUsers(withOnSuccess: nil, onFailure: nil)
307316
}
317+
318+
/// Disable this device's token in Iterable, for all users on this device.
319+
@nonobjc
320+
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
321+
public static func disableDeviceForAllUsers() async throws {
322+
try await disableDeviceAsync { onSuccess, onFailure in
323+
disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure)
324+
}
325+
}
308326

309327
/// Disable this device's token in Iterable, for the current user, with custom completion blocks
310328
///
@@ -331,6 +349,29 @@ import UIKit
331349

332350
implementation.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure)
333351
}
352+
353+
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
354+
private static func disableDeviceAsync(
355+
_ disableDevice: (_ onSuccess: OnSuccessHandler?, _ onFailure: OnFailureHandler?) -> Void
356+
) async throws {
357+
guard let implementation, implementation.isSDKInitialized() else {
358+
throw SendRequestError(reason: sdkNotInitializedErrorReason)
359+
}
360+
361+
try await withCheckedThrowingContinuation { continuation in
362+
let resumeGuard = AsyncContinuationResumeGuard()
363+
364+
disableDevice({ _ in
365+
resumeGuard.resume {
366+
continuation.resume(returning: ())
367+
}
368+
}, { reason, data in
369+
resumeGuard.resume {
370+
continuation.resume(throwing: SendRequestError(reason: reason, data: data))
371+
}
372+
})
373+
}
374+
}
334375

335376
/// Updates the available user fields
336377
///
@@ -865,7 +906,27 @@ import UIKit
865906

866907
// MARK: - Private/Internal
867908

909+
private static let sdkNotInitializedErrorReason = "Iterable SDK is not initialized"
910+
868911
static var implementation: InternalIterableAPI?
869912

870913
override private init() { super.init() }
871914
}
915+
916+
private final class AsyncContinuationResumeGuard {
917+
private let lock = NSLock()
918+
private var didResume = false
919+
920+
func resume(_ block: () -> Void) {
921+
lock.lock()
922+
let shouldResume = !didResume
923+
if shouldResume {
924+
didResume = true
925+
}
926+
lock.unlock()
927+
928+
if shouldResume {
929+
block()
930+
}
931+
}
932+
}

tests/unit-tests/IterableAPITests.swift

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,102 @@ class IterableAPITests: XCTestCase {
681681

682682
wait(for: [expectation], timeout: testExpectationTimeout)
683683
}
684+
685+
@available(iOS 13.0, *)
686+
func testDisableDeviceForCurrentUserAsyncSuccess() async throws {
687+
let oldImplementation = IterableAPI.implementation
688+
defer { IterableAPI.implementation = oldImplementation }
689+
690+
let networkSession = MockNetworkSession(statusCode: 200)
691+
let token = try await setUpIterableAPIForAsyncDisableDevice(networkSession: networkSession)
692+
693+
try await IterableAPI.disableDeviceForCurrentUser()
694+
695+
guard let request = networkSession.getRequest(withEndPoint: Const.Path.disableDevice) else {
696+
return XCTFail("Expected disableDevice request")
697+
}
698+
guard let body = TestUtils.getRequestBody(request: request) else {
699+
return XCTFail("Expected disableDevice request body")
700+
}
701+
702+
TestUtils.validate(request: request,
703+
requestType: .post,
704+
apiEndPoint: Endpoint.api,
705+
path: Const.Path.disableDevice,
706+
queryParams: [])
707+
TestUtils.validateElementPresent(withName: JsonKey.token, andValue: token.hexString(), inDictionary: body)
708+
TestUtils.validateElementPresent(withName: JsonKey.email, andValue: "user@example.com", inDictionary: body)
709+
}
710+
711+
@available(iOS 13.0, *)
712+
func testDisableDeviceForAllUsersAsyncSuccess() async throws {
713+
let oldImplementation = IterableAPI.implementation
714+
defer { IterableAPI.implementation = oldImplementation }
715+
716+
let networkSession = MockNetworkSession(statusCode: 200)
717+
let token = try await setUpIterableAPIForAsyncDisableDevice(networkSession: networkSession)
718+
719+
try await IterableAPI.disableDeviceForAllUsers()
720+
721+
guard let request = networkSession.getRequest(withEndPoint: Const.Path.disableDevice) else {
722+
return XCTFail("Expected disableDevice request")
723+
}
724+
guard let body = TestUtils.getRequestBody(request: request) else {
725+
return XCTFail("Expected disableDevice request body")
726+
}
727+
728+
TestUtils.validate(request: request,
729+
requestType: .post,
730+
apiEndPoint: Endpoint.api,
731+
path: Const.Path.disableDevice,
732+
queryParams: [])
733+
TestUtils.validateElementPresent(withName: JsonKey.token, andValue: token.hexString(), inDictionary: body)
734+
TestUtils.validateElementNotPresent(withName: JsonKey.email, inDictionary: body)
735+
TestUtils.validateElementNotPresent(withName: JsonKey.userId, inDictionary: body)
736+
}
737+
738+
@available(iOS 13.0, *)
739+
func testDisableDeviceForCurrentUserAsyncFailureThrowsSendRequestError() async throws {
740+
let oldImplementation = IterableAPI.implementation
741+
defer { IterableAPI.implementation = oldImplementation }
742+
743+
let failureReason = "disable failed"
744+
let networkSession = MockNetworkSession(responseCallback: { url in
745+
if url.absoluteString.contains(Const.Path.disableDevice) {
746+
return MockNetworkSession.MockResponse(statusCode: 400,
747+
data: ["msg": failureReason].toJsonData())
748+
}
749+
return MockNetworkSession.MockResponse(statusCode: 200)
750+
})
751+
_ = try await setUpIterableAPIForAsyncDisableDevice(networkSession: networkSession)
752+
753+
do {
754+
try await IterableAPI.disableDeviceForCurrentUser()
755+
XCTFail("Expected disableDeviceForCurrentUser async API to throw")
756+
} catch let error as SendRequestError {
757+
XCTAssertEqual(error.reason, failureReason)
758+
XCTAssertEqual(error.data, ["msg": failureReason].toJsonData())
759+
} catch {
760+
XCTFail("Expected SendRequestError, got \(error)")
761+
}
762+
}
763+
764+
@available(iOS 13.0, *)
765+
func testDisableDeviceForCurrentUserAsyncNotInitializedThrowsSendRequestError() async {
766+
let oldImplementation = IterableAPI.implementation
767+
IterableAPI.implementation = nil
768+
defer { IterableAPI.implementation = oldImplementation }
769+
770+
do {
771+
try await IterableAPI.disableDeviceForCurrentUser()
772+
XCTFail("Expected disableDeviceForCurrentUser async API to throw")
773+
} catch let error as SendRequestError {
774+
XCTAssertEqual(error.reason, "Iterable SDK is not initialized")
775+
XCTAssertNil(error.data)
776+
} catch {
777+
XCTFail("Expected SendRequestError, got \(error)")
778+
}
779+
}
684780

685781
func testUpdateCart() {
686782
let condition1 = XCTestExpectation(description: #function)
@@ -1478,4 +1574,54 @@ class IterableAPITests: XCTestCase {
14781574
XCTAssertEqual(dateFromMilliseconds, testDate)
14791575
}
14801576

1577+
@available(iOS 13.0, *)
1578+
private func setUpIterableAPIForAsyncDisableDevice(networkSession: MockNetworkSession) async throws -> Data {
1579+
let config = IterableConfig()
1580+
config.pushIntegrationName = "my-push-integration"
1581+
1582+
IterableAPI.initializeForTesting(apiKey: IterableAPITests.apiKey,
1583+
config: config,
1584+
networkSession: networkSession)
1585+
IterableAPI.email = "user@example.com"
1586+
1587+
let token = "zeeToken".data(using: .utf8)!
1588+
try await registerTokenForAsyncDisableDevice(token)
1589+
return token
1590+
}
1591+
1592+
@available(iOS 13.0, *)
1593+
private func registerTokenForAsyncDisableDevice(_ token: Data) async throws {
1594+
try await withCheckedThrowingContinuation { continuation in
1595+
let resumeGuard = TestAsyncContinuationResumeGuard()
1596+
1597+
IterableAPI.register(token: token, onSuccess: { _ in
1598+
resumeGuard.resume {
1599+
continuation.resume(returning: ())
1600+
}
1601+
}, onFailure: { reason, data in
1602+
resumeGuard.resume {
1603+
continuation.resume(throwing: SendRequestError(reason: reason, data: data))
1604+
}
1605+
})
1606+
}
1607+
}
1608+
1609+
}
1610+
1611+
private final class TestAsyncContinuationResumeGuard {
1612+
private let lock = NSLock()
1613+
private var didResume = false
1614+
1615+
func resume(_ block: () -> Void) {
1616+
lock.lock()
1617+
let shouldResume = !didResume
1618+
if shouldResume {
1619+
didResume = true
1620+
}
1621+
lock.unlock()
1622+
1623+
if shouldResume {
1624+
block()
1625+
}
1626+
}
14811627
}

0 commit comments

Comments
 (0)