Skip to content

Commit 62a33d3

Browse files
sumeruchatclaude
andauthored
SDK-300 Add OnSuccess/OnFailure handlers to logoutUser (#1069)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 481f525 commit 62a33d3

3 files changed

Lines changed: 308 additions & 22 deletions

File tree

swift-sdk/Internal/InternalIterableAPI.swift

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,36 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
250250
func logoutUser() {
251251
logoutPreviousUser()
252252
}
253+
254+
func logoutUser(withOnSuccess onSuccess: OnSuccessHandler?,
255+
onFailure: OnFailureHandler?) {
256+
ITBInfo()
257+
258+
guard isSDKInitialized() else {
259+
onFailure?("Iterable SDK is not initialized", nil)
260+
return
261+
}
262+
263+
if config.autoPushRegistration {
264+
disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure)
265+
}
266+
267+
_email = nil
268+
_userId = nil
269+
270+
storeIdentifierData()
271+
272+
authManager.logoutUser()
273+
274+
_ = inAppManager.reset()
275+
_ = embeddedManager.reset()
276+
277+
try? requestHandler.handleLogout()
278+
279+
if !config.autoPushRegistration {
280+
onSuccess?(nil)
281+
}
282+
}
253283

254284
func attemptAndProcessMerge(merge: Bool, replay: Bool, destinationUser: String?, isEmail: Bool, failureHandler: OnFailureHandler? = nil) {
255285
unknownUserMerge.tryMergeUser(destinationUser: destinationUser, isEmail: isEmail, merge: merge) { mergeResult, error in
@@ -845,25 +875,12 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider {
845875
}
846876

847877
private func logoutPreviousUser() {
848-
ITBInfo()
849-
850-
guard isSDKInitialized() else { return }
851-
852-
if config.autoPushRegistration {
853-
disableDeviceForCurrentUser()
854-
}
855-
856-
_email = nil
857-
_userId = nil
858-
859-
storeIdentifierData()
860-
861-
authManager.logoutUser()
862-
863-
_ = inAppManager.reset()
864-
_ = embeddedManager.reset()
865-
866-
try? requestHandler.handleLogout()
878+
// Delegates to logoutUser(withOnSuccess:onFailure:) so the logout cleanup
879+
// sequence has a single source of truth. The user-switch paths (setEmail/
880+
// setUserId) pass no handlers: a nil onFailure keeps the not-initialized
881+
// guard a silent no-op, and a nil onSuccess makes the auto-push-off
882+
// completion a no-op, matching this method's previous behavior.
883+
logoutUser(withOnSuccess: nil, onFailure: nil)
867884
}
868885

869886
private func storeIdentifierData() {

swift-sdk/SDK/IterableAPI.swift

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,47 @@ import UIKit
212212
/// If `autoPushRegistration` is `true` (which is the default value), this will also
213213
/// disable the current push token.
214214
public static func logoutUser() {
215-
implementation?.logoutUser()
215+
logoutUser(withOnSuccess: nil, onFailure: nil)
216+
}
217+
218+
/// Logs out the current user from the SDK instance, with completion handlers.
219+
///
220+
/// Logout itself is a **local-only** operation: it clears the stored user
221+
/// identity, resets the in-app and embedded managers, and clears auth state.
222+
/// Once the SDK is initialized this local cleanup always runs (and
223+
/// effectively always succeeds).
224+
///
225+
/// The handlers are an **observability** signal for the one network
226+
/// side-effect logout can trigger — the `disableDevice` call made when
227+
/// `autoPushRegistration` is enabled:
228+
/// - When `autoPushRegistration` is `true`, the handlers reflect the result
229+
/// of that `disableDevice` request: `onSuccess` when it succeeds,
230+
/// `onFailure` when it fails or cannot be sent (e.g. no push token is
231+
/// registered, reported as `"no token present"`). An `onFailure` here does
232+
/// **not** mean the local logout failed — the local logout still completed.
233+
/// - When `autoPushRegistration` is `false`, no network call is made and
234+
/// `onSuccess(nil)` is invoked once the local logout sequence completes.
235+
/// - If the SDK is not initialized, `onFailure("Iterable SDK is not
236+
/// initialized", nil)` is invoked and no logout is performed.
237+
///
238+
/// - Note: Handlers are delivered in-process and are not persisted. If the
239+
/// triggered `disableDevice` request is retried offline, the handler is
240+
/// resolved if/when the retry completes within the same app session — it is
241+
/// not preserved across app termination. Handlers are also not guaranteed
242+
/// to be invoked on the main thread.
243+
///
244+
/// - Parameters:
245+
/// - onSuccess: `OnSuccessHandler` to invoke on success (see above)
246+
/// - onFailure: `OnFailureHandler` to invoke on failure (see above)
247+
///
248+
/// - SeeAlso: OnSuccessHandler, OnFailureHandler
249+
public static func logoutUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) {
250+
guard let implementation else {
251+
onFailure?("Iterable SDK is not initialized", nil)
252+
return
253+
}
254+
255+
implementation.logoutUser(withOnSuccess: onSuccess, onFailure: onFailure)
216256
}
217257

218258
/// The instance that manages getting and showing in-app messages
@@ -314,7 +354,10 @@ import UIKit
314354
///
315355
/// - SeeAlso: OnSuccessHandler, OnFailureHandler
316356
public static func disableDeviceForCurrentUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) {
317-
guard let implementation, implementation.isSDKInitialized() else { return }
357+
guard let implementation, implementation.isSDKInitialized() else {
358+
onFailure?("Iterable SDK is not initialized", nil)
359+
return
360+
}
318361

319362
implementation.disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure)
320363
}
@@ -327,7 +370,10 @@ import UIKit
327370
///
328371
/// - SeeAlso: OnSuccessHandler, OnFailureHandler
329372
public static func disableDeviceForAllUsers(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) {
330-
guard let implementation, implementation.isSDKInitialized() else { return }
373+
guard let implementation, implementation.isSDKInitialized() else {
374+
onFailure?("Iterable SDK is not initialized", nil)
375+
return
376+
}
331377

332378
implementation.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure)
333379
}

tests/unit-tests/AuthTests.swift

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,230 @@ class AuthTests: XCTestCase {
12511251
_ = authDelegate
12521252
}
12531253

1254+
func testLogoutUserWithHandlersAutoPushOnDisableDeviceSucceeds() {
1255+
let logoutSucceeded = expectation(description: "logout success handler called")
1256+
let token = "zeeToken".data(using: .utf8)!
1257+
let networkSession = MockNetworkSession(statusCode: 200)
1258+
let context = createLogoutTestContext(autoPushRegistration: true, networkSession: networkSession)
1259+
1260+
context.api.email = AuthTests.email
1261+
context.api.register(token: token)
1262+
1263+
context.api.logoutUser(withOnSuccess: { _ in
1264+
logoutSucceeded.fulfill()
1265+
}, onFailure: { reason, _ in
1266+
XCTFail("logout should not fail: \(reason ?? "nil")")
1267+
})
1268+
1269+
wait(for: [logoutSucceeded], timeout: testExpectationTimeout)
1270+
1271+
assertLogoutCleanupCompleted(context)
1272+
1273+
guard let request = networkSession.getRequest(withEndPoint: Const.Path.disableDevice),
1274+
let body = TestUtils.getRequestBody(request: request) else {
1275+
XCTFail("Expected disableDevice request")
1276+
return
1277+
}
1278+
1279+
TestUtils.validateElementPresent(withName: JsonKey.token, andValue: token.hexString(), inDictionary: body)
1280+
TestUtils.validateElementPresent(withName: JsonKey.email, andValue: AuthTests.email, inDictionary: body)
1281+
}
1282+
1283+
func testLogoutUserWithHandlersAutoPushOnDisableDeviceFails() {
1284+
let logoutFailed = expectation(description: "logout failure handler called")
1285+
let expectedReason = "disable failed"
1286+
let token = "zeeToken".data(using: .utf8)!
1287+
let networkSession = MockNetworkSession(statusCode: 400,
1288+
json: ["msg": expectedReason])
1289+
let context = createLogoutTestContext(autoPushRegistration: true, networkSession: networkSession)
1290+
1291+
context.api.email = AuthTests.email
1292+
context.api.register(token: token)
1293+
1294+
context.api.logoutUser(withOnSuccess: { _ in
1295+
XCTFail("logout should not succeed")
1296+
}, onFailure: { reason, data in
1297+
XCTAssertEqual(reason, expectedReason)
1298+
XCTAssertNotNil(data)
1299+
logoutFailed.fulfill()
1300+
})
1301+
1302+
wait(for: [logoutFailed], timeout: testExpectationTimeout)
1303+
1304+
assertLogoutCleanupCompleted(context)
1305+
1306+
guard let request = networkSession.getRequest(withEndPoint: Const.Path.disableDevice),
1307+
let body = TestUtils.getRequestBody(request: request) else {
1308+
XCTFail("Expected disableDevice request")
1309+
return
1310+
}
1311+
1312+
TestUtils.validateElementPresent(withName: JsonKey.token, andValue: token.hexString(), inDictionary: body)
1313+
TestUtils.validateElementPresent(withName: JsonKey.email, andValue: AuthTests.email, inDictionary: body)
1314+
}
1315+
1316+
func testLogoutUserWithHandlersAutoPushOffSucceedsSynchronouslyWithoutNetwork() {
1317+
let logoutSucceeded = expectation(description: "logout success handler called")
1318+
let networkSession = MockNetworkSession(statusCode: 200)
1319+
let context = createLogoutTestContext(autoPushRegistration: false, networkSession: networkSession)
1320+
1321+
context.api.email = AuthTests.email
1322+
1323+
var logoutReturned = false
1324+
context.api.logoutUser(withOnSuccess: { data in
1325+
XCTAssertNil(data)
1326+
XCTAssertFalse(logoutReturned)
1327+
logoutSucceeded.fulfill()
1328+
}, onFailure: { reason, _ in
1329+
XCTFail("logout should not fail: \(reason ?? "nil")")
1330+
})
1331+
logoutReturned = true
1332+
1333+
wait(for: [logoutSucceeded], timeout: testExpectationTimeout)
1334+
1335+
assertLogoutCleanupCompleted(context)
1336+
XCTAssertTrue(networkSession.requests.isEmpty)
1337+
}
1338+
1339+
func testLogoutUserWithHandlersAutoPushOnNoTokenFailsButStillClearsLocalState() {
1340+
let logoutFailed = expectation(description: "logout failure handler called")
1341+
let networkSession = MockNetworkSession(statusCode: 200)
1342+
let context = createLogoutTestContext(autoPushRegistration: true, networkSession: networkSession)
1343+
1344+
context.api.email = AuthTests.email
1345+
// Intentionally do NOT register a token: there is no push token to disable.
1346+
1347+
context.api.logoutUser(withOnSuccess: { _ in
1348+
XCTFail("logout should not report success when there is no token to disable")
1349+
}, onFailure: { reason, data in
1350+
XCTAssertEqual(reason, "no token present")
1351+
XCTAssertNil(data)
1352+
logoutFailed.fulfill()
1353+
})
1354+
1355+
wait(for: [logoutFailed], timeout: testExpectationTimeout)
1356+
1357+
// Logout is local-only and must still complete even though the triggered
1358+
// disableDevice could not run (no token) and onFailure was reported.
1359+
assertLogoutCleanupCompleted(context)
1360+
XCTAssertNil(networkSession.getRequest(withEndPoint: Const.Path.disableDevice),
1361+
"no disableDevice request should be sent when there is no token")
1362+
}
1363+
1364+
func testLogoutUserWithHandlersNotInitializedFails() {
1365+
let logoutFailed = expectation(description: "logout failure handler called")
1366+
let networkSession = MockNetworkSession(statusCode: 200)
1367+
let context = createLogoutTestContext(autoPushRegistration: true, networkSession: networkSession)
1368+
1369+
context.api.logoutUser(withOnSuccess: { _ in
1370+
XCTFail("logout should not succeed")
1371+
}, onFailure: { reason, data in
1372+
XCTAssertEqual(reason, "Iterable SDK is not initialized")
1373+
XCTAssertNil(data)
1374+
logoutFailed.fulfill()
1375+
})
1376+
1377+
wait(for: [logoutFailed], timeout: testExpectationTimeout)
1378+
1379+
XCTAssertTrue(networkSession.requests.isEmpty)
1380+
XCTAssertEqual(context.inAppManager.resetCallCount, 0)
1381+
XCTAssertEqual(context.embeddedManager.resetCallCount, 0)
1382+
}
1383+
1384+
func testDisableDeviceWithHandlersNotInitializedFails() {
1385+
let previousImplementation = IterableAPI.implementation
1386+
defer { IterableAPI.implementation = previousImplementation }
1387+
IterableAPI.implementation = nil
1388+
let currentUserFailed = expectation(description: "current user disable failure handler called")
1389+
let allUsersFailed = expectation(description: "all users disable failure handler called")
1390+
1391+
IterableAPI.disableDeviceForCurrentUser(withOnSuccess: { _ in
1392+
XCTFail("disable current user should not succeed")
1393+
}, onFailure: { reason, data in
1394+
XCTAssertEqual(reason, "Iterable SDK is not initialized")
1395+
XCTAssertNil(data)
1396+
currentUserFailed.fulfill()
1397+
})
1398+
1399+
IterableAPI.disableDeviceForAllUsers(withOnSuccess: { _ in
1400+
XCTFail("disable all users should not succeed")
1401+
}, onFailure: { reason, data in
1402+
XCTAssertEqual(reason, "Iterable SDK is not initialized")
1403+
XCTAssertNil(data)
1404+
allUsersFailed.fulfill()
1405+
})
1406+
1407+
wait(for: [currentUserFailed, allUsersFailed], timeout: testExpectationTimeout)
1408+
}
1409+
1410+
func testLogoutUserParameterlessOverloadStillLogsOut() {
1411+
let networkSession = MockNetworkSession(statusCode: 200)
1412+
let context = createLogoutTestContext(autoPushRegistration: false, networkSession: networkSession)
1413+
1414+
context.api.email = AuthTests.email
1415+
1416+
context.api.logoutUser()
1417+
1418+
assertLogoutCleanupCompleted(context)
1419+
XCTAssertTrue(networkSession.requests.isEmpty)
1420+
}
1421+
12541422
// MARK: - Private
1423+
1424+
private final class LogoutTrackingInAppManager: EmptyInAppManager {
1425+
private(set) var resetCallCount = 0
1426+
1427+
override func reset() -> Pending<Bool, Error> {
1428+
resetCallCount += 1
1429+
return Fulfill<Bool, Error>(value: true)
1430+
}
1431+
}
1432+
1433+
private final class LogoutTrackingEmbeddedManager: EmptyEmbeddedManager {
1434+
private(set) var resetCallCount = 0
1435+
1436+
override func reset() {
1437+
resetCallCount += 1
1438+
}
1439+
}
1440+
1441+
private typealias LogoutTestContext = (api: InternalIterableAPI,
1442+
localStorage: MockLocalStorage,
1443+
inAppManager: LogoutTrackingInAppManager,
1444+
embeddedManager: LogoutTrackingEmbeddedManager)
1445+
1446+
private func createLogoutTestContext(autoPushRegistration: Bool,
1447+
networkSession: MockNetworkSession) -> LogoutTestContext {
1448+
let config = IterableConfig()
1449+
config.autoPushRegistration = autoPushRegistration
1450+
config.pushIntegrationName = "my-push-integration"
1451+
1452+
let localStorage = MockLocalStorage()
1453+
let api = InternalIterableAPI.initializeForTesting(config: config,
1454+
networkSession: networkSession,
1455+
notificationStateProvider: MockNotificationStateProvider(enabled: true),
1456+
localStorage: localStorage)
1457+
let inAppManager = LogoutTrackingInAppManager()
1458+
let embeddedManager = LogoutTrackingEmbeddedManager()
1459+
api.inAppManager = inAppManager
1460+
api.embeddedManager = embeddedManager
1461+
1462+
return (api: api,
1463+
localStorage: localStorage,
1464+
inAppManager: inAppManager,
1465+
embeddedManager: embeddedManager)
1466+
}
1467+
1468+
private func assertLogoutCleanupCompleted(_ context: LogoutTestContext,
1469+
file: StaticString = #file,
1470+
line: UInt = #line) {
1471+
XCTAssertNil(context.api.email, file: file, line: line)
1472+
XCTAssertNil(context.api.userId, file: file, line: line)
1473+
XCTAssertNil(context.localStorage.email, file: file, line: line)
1474+
XCTAssertNil(context.localStorage.userId, file: file, line: line)
1475+
XCTAssertEqual(context.inAppManager.resetCallCount, 1, file: file, line: line)
1476+
XCTAssertEqual(context.embeddedManager.resetCallCount, 1, file: file, line: line)
1477+
}
12551478

12561479
class DefaultAuthDelegate: IterableAuthDelegate {
12571480
var authTokenGenerator: (() -> String?)

0 commit comments

Comments
 (0)