From 1c316e1e50878e9a2f9afb84d809479a92150b94 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Wed, 29 Apr 2026 00:29:03 +0100 Subject: [PATCH 1/4] Removed User word from every UUA scope. --- swift-sdk/Core/Constants.swift | 19 ++- swift-sdk/Internal/InternalIterableAPI.swift | 20 +-- .../Internal/IterableIdentityResolution.swift | 19 ++- swift-sdk/Internal/IterableUserDefaults.swift | 57 ++++++- swift-sdk/Internal/Models.swift | 59 +++++++ swift-sdk/Internal/UnknownUserManager.swift | 79 +++++++--- .../Internal/UnknownUserManagerProtocol.swift | 25 ++- .../Utilities/Keychain/IterableKeychain.swift | 21 ++- swift-sdk/Internal/api-client/ApiClient.swift | 4 +- .../api-client/ApiClientProtocol.swift | 2 +- .../api-client/Request/RequestCreator.swift | 4 +- swift-sdk/SDK/IterableAPI.swift | 4 +- swift-sdk/SDK/IterableConfig.swift | 2 +- tests/unit-tests/BlankApiClient.swift | 2 +- .../IterableApiCriteriaFetchTests.swift | 8 +- .../UUANormalizationMigrationTests.swift | 146 ++++++++++++++++++ .../unit-tests/UserMergeScenariosTests.swift | 32 ++-- .../ValidateTokenForDestinationUserTest.swift | 4 +- 18 files changed, 424 insertions(+), 83 deletions(-) create mode 100644 tests/unit-tests/UUANormalizationMigrationTests.swift diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index f2dd4423c..438c07537 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -53,7 +53,7 @@ enum Const { static let getRemoteConfiguration = "mobile/getRemoteConfiguration" static let mergeUser = "users/merge"; static let getCriteria = "unknownuser/list"; - static let trackUnknownUserSession = "unknownuser/events/session"; + static let trackUnknownSession = "unknownuser/events/session"; static let trackConsent = "unknownuser/consent"; static let getEmbeddedMessages = "embedded-messaging/messages" static let embeddedMessageReceived = "embedded-messaging/events/received" @@ -67,7 +67,7 @@ enum Const { getRemoteConfiguration, mergeUser, getCriteria, - trackUnknownUserSession, + trackUnknownSession, trackConsent, ] @@ -89,7 +89,9 @@ enum Const { static let unknownUserEvents = "itbl_unknown_user_events" static let unknownUserUpdate = "itbl_unknown_user_update" static let criteriaData = "itbl_criteria_data" - static let unknownUserSessions = "itbl_unknown_user_sessions" + static let unknownUserSessions = "itbl_unknown_sessions" + /// Legacy key used prior to UUA naming normalization. Read for one-shot migration only. + static let legacyUnknownUserSessions = "itbl_unknown_user_sessions" static let matchedCriteria = "itbl_matched_criteria" static let eventList = "itbl_event_list" static let visitorUsageTracked = "itbl_visitor_usage_tracked" @@ -106,7 +108,9 @@ enum Const { enum Key { static let email = "itbl_email" static let userId = "itbl_userid" - static let userIdUnknownUser = "itbl_userid_unknown_user" + static let userIdUnknownUser = "itbl_userid_unknown" + /// Legacy key used prior to UUA naming normalization. Read for one-shot migration only. + static let legacyUserIdUnknownUser = "itbl_userid_unknown_user" static let authToken = "itbl_auth_token" } } @@ -226,9 +230,12 @@ enum JsonKey { static let contentType = "Content-Type" - // AUT + // UUA static let createNewFields = "createNewFields" - static let eventType = "dataType" + static let eventType = "eventType" + /// Legacy event-type discriminator key written to local storage prior to UUA naming + /// normalization. Read for one-shot migration only. + static let legacyEventType = "dataType" static let eventTimeStamp = "eventTimeStamp" static let criteriaSets = "criteriaSets" static let matchedCriteriaId = "matchedCriteriaId" diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index 57cde1ae5..8fe93d85c 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -165,7 +165,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { guard let config = self?.config else { return } - let merge = identityResolution?.mergeOnUnknownUserToKnown ?? config.identityResolution.mergeOnUnknownUserToKnown + let merge = identityResolution?.mergeOnUnknownToKnown ?? config.identityResolution.mergeOnUnknownToKnown let replay = identityResolution?.replayOnVisitorToKnown ?? config.identityResolution.replayOnVisitorToKnown if config.enableUnknownUserActivation, let email = email { // Prepare consent for replay scenario before merge @@ -216,7 +216,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } if config.enableUnknownUserActivation { if let userId = userId, userId != (self?.localStorage.userIdUnknownUser ?? "") { - let merge = identityResolution?.mergeOnUnknownUserToKnown ?? config.identityResolution.mergeOnUnknownUserToKnown + let merge = identityResolution?.mergeOnUnknownToKnown ?? config.identityResolution.mergeOnUnknownToKnown let replay = identityResolution?.replayOnVisitorToKnown ?? config.identityResolution.replayOnVisitorToKnown // Prepare consent for replay scenario before merge @@ -313,8 +313,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { if isVisitorUsageTracked && config.enableUnknownUserActivation { ITBInfo("CONSENT GIVEN and UNKNOWN USER TRACKING ENABLED - Criteria fetched") - self.unknownUserManager.getUnknownUserCriteria() - self.unknownUserManager.updateUnknownUserSession() + self.unknownUserManager.getUnknownCriteria() + self.unknownUserManager.updateUnknownSession() } } @@ -407,7 +407,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { if config.enableUnknownUserActivation { - unknownUserManager.trackUnknownUserTokenRegistration(token: token) + unknownUserManager.trackUnknownTokenRegistration(token: token) } onFailure?("Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods", nil) return @@ -497,7 +497,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { if config.enableUnknownUserActivation { ITBInfo("UUA ENABLED - unknown user update user") - unknownUserManager.trackUnknownUserUpdateUser(dataFields) + unknownUserManager.trackUnknownUpdateUser(dataFields) } return rejectWithInitializationError(onFailure: onFailure) } @@ -529,7 +529,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { if config.enableUnknownUserActivation { ITBInfo("UUA ENABLED - unknown user update cart") - unknownUserManager.trackUnknownUserUpdateCart(items: items) + unknownUserManager.trackUnknownUpdateCart(items: items) } return rejectWithInitializationError(onFailure: onFailure) } @@ -562,7 +562,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { if !isEitherUserIdOrEmailSet() { if config.enableUnknownUserActivation { ITBInfo("UUA ENABLED - unknown user track purchase") - unknownUserManager.trackUnknownUserPurchaseEvent(total: total, items: items, dataFields: dataFields) + unknownUserManager.trackUnknownPurchaseEvent(total: total, items: items, dataFields: dataFields) } return rejectWithInitializationError(onFailure: onFailure) } @@ -636,7 +636,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { if config.enableUnknownUserActivation { ITBInfo("UUA ENABLED - unknown user track custom event") - unknownUserManager.trackUnknownUserEvent(name: eventName, dataFields: dataFields) + unknownUserManager.trackUnknownEvent(name: eventName, dataFields: dataFields) } return rejectWithInitializationError(onFailure: onFailure) } @@ -1072,7 +1072,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { && (currentTime - unknownUserManager.getLastCriteriaFetch() >= Const.criteriaFetchingCooldown) { unknownUserManager.updateLastCriteriaFetch(currentTime: currentTime) - unknownUserManager.getUnknownUserCriteria() + unknownUserManager.getUnknownCriteria() ITBInfo("Fetching unknown user criteria - Foreground") } } diff --git a/swift-sdk/Internal/IterableIdentityResolution.swift b/swift-sdk/Internal/IterableIdentityResolution.swift index caa02c8df..93be1a747 100644 --- a/swift-sdk/Internal/IterableIdentityResolution.swift +++ b/swift-sdk/Internal/IterableIdentityResolution.swift @@ -13,11 +13,24 @@ import Foundation public var replayOnVisitorToKnown: Bool? /// When true, merges the unknown user profile with the known user profile on identification. - public let mergeOnUnknownUserToKnown: Bool? + public let mergeOnUnknownToKnown: Bool? public init(replayOnVisitorToKnown: Bool?, - mergeOnUnknownUserToKnown: Bool?) { + mergeOnUnknownToKnown: Bool?) { self.replayOnVisitorToKnown = replayOnVisitorToKnown - self.mergeOnUnknownUserToKnown = mergeOnUnknownUserToKnown + self.mergeOnUnknownToKnown = mergeOnUnknownToKnown + } + + // MARK: - Deprecated alias (remove in next major) + + /// - Note: This property is kept for backward compatibility. Use ``mergeOnUnknownToKnown`` instead. + @available(*, deprecated, renamed: "mergeOnUnknownToKnown") + public var mergeOnUnknownUserToKnown: Bool? { mergeOnUnknownToKnown } + + @available(*, deprecated, renamed: "init(replayOnVisitorToKnown:mergeOnUnknownToKnown:)") + public convenience init(replayOnVisitorToKnown: Bool?, + mergeOnUnknownUserToKnown: Bool?) { + self.init(replayOnVisitorToKnown: replayOnVisitorToKnown, + mergeOnUnknownToKnown: mergeOnUnknownUserToKnown) } } diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index c3fbe9ad9..82580eb00 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -96,19 +96,53 @@ class IterableUserDefaults { var unknownUserEvents: [[AnyHashable: Any]]? { get { - return eventData(withKey: .unknownUserEvents) + guard let raw = eventData(withKey: .unknownUserEvents) else { return nil } + let (normalized, didMigrate) = Self.normalizeLegacyEventTypeKey(in: raw) + if didMigrate { + saveEventData(unknownUserEvents: normalized, withKey: .unknownUserEvents) + } + return normalized } set { saveEventData(unknownUserEvents: newValue, withKey: .unknownUserEvents) } } - + var unknownUserUpdate: [AnyHashable: Any]? { get { - return userUpdateData(withKey: .unknownUserUpdate) + guard let raw = userUpdateData(withKey: .unknownUserUpdate) else { return nil } + let (normalized, didMigrate) = Self.normalizeLegacyEventTypeKey(in: raw) + if didMigrate { + saveUserUpdate(normalized, withKey: .unknownUserUpdate) + } + return normalized } set { saveUserUpdate(newValue, withKey: .unknownUserUpdate) } } + + /// Migrates locally stored entries written prior to UUA naming normalization, + /// where the event-type discriminator was keyed as `"dataType"`. Returns the + /// normalized payload and whether any migration occurred. + private static func normalizeLegacyEventTypeKey(in events: [[AnyHashable: Any]]) -> ([[AnyHashable: Any]], Bool) { + var didMigrate = false + let normalized = events.map { event -> [AnyHashable: Any] in + let (out, migrated) = normalizeLegacyEventTypeKey(in: event) + if migrated { didMigrate = true } + return out + } + return (normalized, didMigrate) + } + + private static func normalizeLegacyEventTypeKey(in event: [AnyHashable: Any]) -> ([AnyHashable: Any], Bool) { + guard event[JsonKey.eventType] == nil, + let legacy = event[JsonKey.legacyEventType] else { + return (event, false) + } + var migrated = event + migrated[JsonKey.eventType] = legacy + migrated.removeValue(forKey: JsonKey.legacyEventType) + return (migrated, true) + } var criteriaData: Data? { get { @@ -130,8 +164,20 @@ class IterableUserDefaults { private func unknownUserSessionsData(withKey key: UserDefaultsKey) -> IterableUnknownUserSessionsWrapper? { if let savedData = UserDefaults.standard.data(forKey: key.value) { - let decodedData = try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: savedData) - return decodedData + return try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: savedData) + } + // One-shot migration from the legacy UserDefaults key, if present. + let legacyKey = UserDefaultsKey.legacyUnknownUserSessions + if let legacyData = UserDefaults.standard.data(forKey: legacyKey.value) { + let decoded = try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: legacyData) + if let decoded = decoded { + if let reEncoded = try? JSONEncoder().encode(decoded) { + userDefaults.set(reEncoded, forKey: key.value) + } + userDefaults.removeObject(forKey: legacyKey.value) + return decoded + } + userDefaults.removeObject(forKey: legacyKey.value) } return nil } @@ -350,6 +396,7 @@ class IterableUserDefaults { static let unknownUserUpdate = UserDefaultsKey(value: Const.UserDefault.unknownUserUpdate) static let criteriaData = UserDefaultsKey(value: Const.UserDefault.criteriaData) static let unknownUserSessions = UserDefaultsKey(value: Const.UserDefault.unknownUserSessions) + static let legacyUnknownUserSessions = UserDefaultsKey(value: Const.UserDefault.legacyUnknownUserSessions) static let visitorUsageTracked = UserDefaultsKey(value: Const.UserDefault.visitorUsageTracked) static let visitorConsentTimestamp = UserDefaultsKey(value: Const.UserDefault.visitorConsentTimestamp) diff --git a/swift-sdk/Internal/Models.swift b/swift-sdk/Internal/Models.swift index 7a0e0bb0e..cf4bc1ea6 100644 --- a/swift-sdk/Internal/Models.swift +++ b/swift-sdk/Internal/Models.swift @@ -38,8 +38,67 @@ struct IterableUnknownUserSessions: Codable { var totalUnknownUserSessionCount: Int var lastUnknownUserSession: Int var firstUnknownUserSession: Int + + enum CodingKeys: String, CodingKey { + case totalUnknownUserSessionCount = "totalUnknownSessionCount" + case lastUnknownUserSession = "lastUnknownSession" + case firstUnknownUserSession = "firstUnknownSession" + } + + // Legacy keys, kept only for one-shot decode migration. + private enum LegacyCodingKeys: String, CodingKey { + case totalUnknownUserSessionCount + case lastUnknownUserSession + case firstUnknownUserSession + } + + init(totalUnknownUserSessionCount: Int, + lastUnknownUserSession: Int, + firstUnknownUserSession: Int) { + self.totalUnknownUserSessionCount = totalUnknownUserSessionCount + self.lastUnknownUserSession = lastUnknownUserSession + self.firstUnknownUserSession = firstUnknownUserSession + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let count = try container.decodeIfPresent(Int.self, forKey: .totalUnknownUserSessionCount) { + // New schema branch + self.totalUnknownUserSessionCount = count + self.lastUnknownUserSession = try container.decode(Int.self, forKey: .lastUnknownUserSession) + self.firstUnknownUserSession = try container.decode(Int.self, forKey: .firstUnknownUserSession) + } else { + // Fallback to legacy schema with "User" in the keys. + let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) + self.totalUnknownUserSessionCount = try legacy.decode(Int.self, forKey: .totalUnknownUserSessionCount) + self.lastUnknownUserSession = try legacy.decode(Int.self, forKey: .lastUnknownUserSession) + self.firstUnknownUserSession = try legacy.decode(Int.self, forKey: .firstUnknownUserSession) + } + } } struct IterableUnknownUserSessionsWrapper: Codable { var itbl_unknown_user_sessions: IterableUnknownUserSessions + + enum CodingKeys: String, CodingKey { + case itbl_unknown_user_sessions = "itbl_unknown_sessions" + } + + private enum LegacyCodingKeys: String, CodingKey { + case itbl_unknown_user_sessions + } + + init(itbl_unknown_user_sessions: IterableUnknownUserSessions) { + self.itbl_unknown_user_sessions = itbl_unknown_user_sessions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let sessions = try container.decodeIfPresent(IterableUnknownUserSessions.self, forKey: .itbl_unknown_user_sessions) { + self.itbl_unknown_user_sessions = sessions + } else { + let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) + self.itbl_unknown_user_sessions = try legacy.decode(IterableUnknownUserSessions.self, forKey: .itbl_unknown_user_sessions) + } + } } diff --git a/swift-sdk/Internal/UnknownUserManager.swift b/swift-sdk/Internal/UnknownUserManager.swift index fe5bf03da..593ae44e7 100644 --- a/swift-sdk/Internal/UnknownUserManager.swift +++ b/swift-sdk/Internal/UnknownUserManager.swift @@ -31,7 +31,7 @@ public class UnknownUserManager: UnknownUserManagerProtocol { private var isCriteriaMatched = false /// Tracks an unknown user event and store it locally - public func trackUnknownUserEvent(name: String, dataFields: [AnyHashable: Any]?) { + public func trackUnknownEvent(name: String, dataFields: [AnyHashable: Any]?) { var body = [AnyHashable: Any]() body.setValue(for: JsonKey.eventName, value: name) body.setValue(for: JsonKey.Body.createdAt, value: IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) @@ -41,14 +41,14 @@ public class UnknownUserManager: UnknownUserManagerProtocol { } storeEventData(type: EventType.customEvent, data: body) } - + /// Tracks an unknown user update event and store it locally - public func trackUnknownUserUpdateUser(_ dataFields: [AnyHashable: Any]) { + public func trackUnknownUpdateUser(_ dataFields: [AnyHashable: Any]) { storeEventData(type: EventType.updateUser, data: dataFields, shouldOverWrite: true) } - + /// Tracks an unknown user purchase event and store it locally - public func trackUnknownUserPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) { + public func trackUnknownPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) { var body = [AnyHashable: Any]() body.setValue(for: JsonKey.Body.createdAt, value:IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) body.setValue(for: JsonKey.Commerce.total, value: total.stringValue) @@ -58,24 +58,24 @@ public class UnknownUserManager: UnknownUserManagerProtocol { } storeEventData(type: EventType.purchase, data: body) } - + /// Tracks an unknown user cart event and store it locally - public func trackUnknownUserUpdateCart(items: [CommerceItem]) { + public func trackUnknownUpdateCart(items: [CommerceItem]) { var body = [AnyHashable: Any]() body.setValue(for: JsonKey.Body.createdAt, value: IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) body.setValue(for: JsonKey.Commerce.items, value: convertCommerceItemsToDictionary(items)) storeEventData(type: EventType.updateCart, data: body) } - + /// Tracks an unknown user token registration event and store it locally - public func trackUnknownUserTokenRegistration(token: String) { + public func trackUnknownTokenRegistration(token: String) { var body = [AnyHashable: Any]() body.setValue(for: JsonKey.token, value: token) storeEventData(type: EventType.tokenRegistration, data: body) } - + /// Stores an unknown user sessions locally. Updates the last session time each time when new session is created - public func updateUnknownUserSession() { + public func updateUnknownSession() { if var sessions = localStorage.unknownUserSessions { sessions.itbl_unknown_user_sessions.totalUnknownUserSessionCount += 1 sessions.itbl_unknown_user_sessions.lastUnknownUserSession = IterableUtil.secondsFromEpoch(for: dateProvider.currentDate) @@ -87,6 +87,38 @@ public class UnknownUserManager: UnknownUserManagerProtocol { localStorage.unknownUserSessions = unknownUserSessionWrapper } } + + // MARK: - Deprecated aliases (forward to new names; remove in next major) + + @available(*, deprecated, renamed: "trackUnknownEvent(name:dataFields:)") + public func trackUnknownUserEvent(name: String, dataFields: [AnyHashable: Any]?) { + trackUnknownEvent(name: name, dataFields: dataFields) + } + + @available(*, deprecated, renamed: "trackUnknownUpdateUser(_:)") + public func trackUnknownUserUpdateUser(_ dataFields: [AnyHashable: Any]) { + trackUnknownUpdateUser(dataFields) + } + + @available(*, deprecated, renamed: "trackUnknownPurchaseEvent(total:items:dataFields:)") + public func trackUnknownUserPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) { + trackUnknownPurchaseEvent(total: total, items: items, dataFields: dataFields) + } + + @available(*, deprecated, renamed: "trackUnknownUpdateCart(items:)") + public func trackUnknownUserUpdateCart(items: [CommerceItem]) { + trackUnknownUpdateCart(items: items) + } + + @available(*, deprecated, renamed: "trackUnknownTokenRegistration(token:)") + public func trackUnknownUserTokenRegistration(token: String) { + trackUnknownTokenRegistration(token: token) + } + + @available(*, deprecated, renamed: "updateUnknownSession()") + public func updateUnknownUserSession() { + updateUnknownSession() + } /// Syncs unsynced data which might have failed to sync when calling syncEvents for the first time after criterias met public func syncNonSyncedEvents() { @@ -99,8 +131,13 @@ public class UnknownUserManager: UnknownUserManagerProtocol { public func syncEvents() { if let events = localStorage.unknownUserEvents { for var eventData in events { - if let eventType = eventData[JsonKey.eventType] as? String { + // Read the new key first; fall back to the legacy `dataType` key to migrate + // events that were stored prior to UUA naming normalization. + let eventTypeValue = (eventData[JsonKey.eventType] as? String) + ?? (eventData[JsonKey.legacyEventType] as? String) + if let eventType = eventTypeValue { eventData.removeValue(forKey: JsonKey.eventType) + eventData.removeValue(forKey: JsonKey.legacyEventType) switch eventType { case EventType.customEvent: IterableAPI.implementation?.track(eventData[JsonKey.eventName] as? String ?? "", withBody: eventData) @@ -132,10 +169,9 @@ public class UnknownUserManager: UnknownUserManagerProtocol { } if var userUpdate = localStorage.unknownUserUpdate { - if userUpdate[JsonKey.eventType] is String { - userUpdate.removeValue(forKey: JsonKey.eventType) - } - + userUpdate.removeValue(forKey: JsonKey.eventType) + userUpdate.removeValue(forKey: JsonKey.legacyEventType) + IterableAPI.implementation?.updateUser(userUpdate, mergeNestedObjects: false) } } @@ -147,13 +183,18 @@ public class UnknownUserManager: UnknownUserManagerProtocol { } /// Gets the unknown user criteria and updates the last criteria fetch time in milliseconds - public func getUnknownUserCriteria() { + public func getUnknownCriteria() { updateLastCriteriaFetch(currentTime: Date().timeIntervalSince1970 * 1000) IterableAPI.implementation?.getCriteriaData { returnedData in self.localStorage.criteriaData = returnedData }; } + + @available(*, deprecated, renamed: "getUnknownCriteria()") + public func getUnknownUserCriteria() { + getUnknownCriteria() + } /// Gets the last criteria fetch time in milliseconds public func getLastCriteriaFetch() -> Double { @@ -177,7 +218,7 @@ public class UnknownUserManager: UnknownUserManagerProtocol { } //track unknown user session for new user - IterableAPI.implementation?.apiClient.trackUnknownUserSession( + IterableAPI.implementation?.apiClient.trackUnknownSession( createdAt: IterableUtil.secondsFromEpoch(for: self.dateProvider.currentDate), withUserId: userId, dataFields: self.localStorage.unknownUserUpdate, @@ -185,7 +226,7 @@ public class UnknownUserManager: UnknownUserManagerProtocol { ).onError { error in self.isCriteriaMatched = false if error.httpStatusCode == 409 { - self.getUnknownUserCriteria() // refetch the criteria + self.getUnknownCriteria() // refetch the criteria } }.onSuccess { success in self.localStorage.userIdUnknownUser = userId diff --git a/swift-sdk/Internal/UnknownUserManagerProtocol.swift b/swift-sdk/Internal/UnknownUserManagerProtocol.swift index 5627f1ee3..d987d5af0 100644 --- a/swift-sdk/Internal/UnknownUserManagerProtocol.swift +++ b/swift-sdk/Internal/UnknownUserManagerProtocol.swift @@ -6,15 +6,32 @@ // import Foundation @objc public protocol UnknownUserManagerProtocol { + func trackUnknownEvent(name: String, dataFields: [AnyHashable: Any]?) + func trackUnknownPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) + func trackUnknownUpdateCart(items: [CommerceItem]) + func trackUnknownTokenRegistration(token: String) + func trackUnknownUpdateUser(_ dataFields: [AnyHashable: Any]) + func updateUnknownSession() + func getLastCriteriaFetch() -> Double + func updateLastCriteriaFetch(currentTime: Double) + func getUnknownCriteria() + func syncEvents() + func clearVisitorEventsAndUserData() + + // MARK: - Deprecated aliases (remove in next major) + + @available(*, deprecated, renamed: "trackUnknownEvent(name:dataFields:)") func trackUnknownUserEvent(name: String, dataFields: [AnyHashable: Any]?) + @available(*, deprecated, renamed: "trackUnknownPurchaseEvent(total:items:dataFields:)") func trackUnknownUserPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) + @available(*, deprecated, renamed: "trackUnknownUpdateCart(items:)") func trackUnknownUserUpdateCart(items: [CommerceItem]) + @available(*, deprecated, renamed: "trackUnknownTokenRegistration(token:)") func trackUnknownUserTokenRegistration(token: String) + @available(*, deprecated, renamed: "trackUnknownUpdateUser(_:)") func trackUnknownUserUpdateUser(_ dataFields: [AnyHashable: Any]) + @available(*, deprecated, renamed: "updateUnknownSession()") func updateUnknownUserSession() - func getLastCriteriaFetch() -> Double - func updateLastCriteriaFetch(currentTime: Double) + @available(*, deprecated, renamed: "getUnknownCriteria()") func getUnknownUserCriteria() - func syncEvents() - func clearVisitorEventsAndUserData() } diff --git a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift index 981cc5028..540c7cbdd 100644 --- a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift +++ b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift @@ -101,19 +101,30 @@ class IterableKeychain { var userIdUnknownUser: String? { get { - let data = wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser) - - return data.flatMap { String(data: $0, encoding: .utf8) } + if let data = wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser), + let value = String(data: data, encoding: .utf8) { + return value + } + // One-shot migration from the legacy key used prior to UUA naming normalization. + if let legacyData = wrapper.data(forKey: Const.Keychain.Key.legacyUserIdUnknownUser), + let legacyValue = String(data: legacyData, encoding: .utf8) { + wrapper.set(legacyData, forKey: Const.Keychain.Key.userIdUnknownUser) + wrapper.removeValue(forKey: Const.Keychain.Key.legacyUserIdUnknownUser) + return legacyValue + } + return nil } - + set { guard let token = newValue, let data = token.data(using: .utf8) else { wrapper.removeValue(forKey: Const.Keychain.Key.userIdUnknownUser) + wrapper.removeValue(forKey: Const.Keychain.Key.legacyUserIdUnknownUser) return } - + wrapper.set(data, forKey: Const.Keychain.Key.userIdUnknownUser) + wrapper.removeValue(forKey: Const.Keychain.Key.legacyUserIdUnknownUser) } } diff --git a/swift-sdk/Internal/api-client/ApiClient.swift b/swift-sdk/Internal/api-client/ApiClient.swift index 35593a159..ac8b0ed9f 100644 --- a/swift-sdk/Internal/api-client/ApiClient.swift +++ b/swift-sdk/Internal/api-client/ApiClient.swift @@ -289,8 +289,8 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } - func trackUnknownUserSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending { - let result = createRequestCreator().flatMap { $0.createTrackUnknownUserSessionRequest(createdAt: createdAt, withUserId: userId, dataFields: dataFields, requestJson: requestJson) } + func trackUnknownSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackUnknownSessionRequest(createdAt: createdAt, withUserId: userId, dataFields: dataFields, requestJson: requestJson) } return send(iterableRequestResult: result) } diff --git a/swift-sdk/Internal/api-client/ApiClientProtocol.swift b/swift-sdk/Internal/api-client/ApiClientProtocol.swift index 97cadbc10..cee8444df 100644 --- a/swift-sdk/Internal/api-client/ApiClientProtocol.swift +++ b/swift-sdk/Internal/api-client/ApiClientProtocol.swift @@ -56,7 +56,7 @@ protocol ApiClientProtocol: AnyObject { func getCriteria() -> Pending - func trackUnknownUserSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending + func trackUnknownSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending func trackConsent(consentTimestamp: Int64, email: String?, userId: String?, isUserKnown: Bool) -> Pending diff --git a/swift-sdk/Internal/api-client/Request/RequestCreator.swift b/swift-sdk/Internal/api-client/Request/RequestCreator.swift index 5b817a4a6..1382413b4 100644 --- a/swift-sdk/Internal/api-client/Request/RequestCreator.swift +++ b/swift-sdk/Internal/api-client/Request/RequestCreator.swift @@ -673,7 +673,7 @@ struct RequestCreator { return .success(.get(createGetRequest(forPath: Const.Path.getCriteria, withArgs: body as! [String: String]))) } - func createTrackUnknownUserSessionRequest(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Result { + func createTrackUnknownSessionRequest(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Result { var body = [AnyHashable: Any]() var userDict = [AnyHashable: Any]() @@ -689,7 +689,7 @@ struct RequestCreator { body.setValue(for: JsonKey.Body.createdAt, value: createdAt) body.setValue(for: JsonKey.deviceInfo, value: deviceMetadata.asDictionary()) body.setValue(for: JsonKey.unknownSessionContext, value: requestJson) - return .success(.post(createPostRequest(path: Const.Path.trackUnknownUserSession, body: body))) + return .success(.post(createPostRequest(path: Const.Path.trackUnknownSession, body: body))) } func createTrackConsentRequest(consentTimestamp: Int64, email: String?, userId: String?, isUserKnown: Bool) -> Result { diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index ccfea33db..d156cc7c4 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -128,8 +128,8 @@ import UIKit if let implementation, config.enableUnknownUserActivation, !implementation.isSDKInitialized(), implementation.getVisitorUsageTracked() { ITBInfo("UUA ENABLED AND CONSENT GIVEN - Criteria fetched") - implementation.unknownUserManager.getUnknownUserCriteria() - implementation.unknownUserManager.updateUnknownUserSession() + implementation.unknownUserManager.getUnknownCriteria() + implementation.unknownUserManager.updateUnknownSession() } } diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index e236b9de7..c9153671f 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -200,7 +200,7 @@ public class IterableConfig: NSObject { // How many events can be stored in the local storage. By default limt is 100. public var eventThresholdLimit: Int = 100 - public var identityResolution: IterableIdentityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + public var identityResolution: IterableIdentityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) /// The type of mobile framework we are using. public var mobileFrameworkInfo: IterableAPIMobileFrameworkInfo? diff --git a/tests/unit-tests/BlankApiClient.swift b/tests/unit-tests/BlankApiClient.swift index 14b377b87..b8dc84ec5 100644 --- a/tests/unit-tests/BlankApiClient.swift +++ b/tests/unit-tests/BlankApiClient.swift @@ -24,7 +24,7 @@ class BlankApiClient: ApiClientProtocol { Pending() } - func trackUnknownUserSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable : Any]?, requestJson: [AnyHashable : Any]) -> IterableSDK.Pending { + func trackUnknownSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable : Any]?, requestJson: [AnyHashable : Any]) -> IterableSDK.Pending { Pending() } diff --git a/tests/unit-tests/IterableApiCriteriaFetchTests.swift b/tests/unit-tests/IterableApiCriteriaFetchTests.swift index 672cc5f1f..9915df215 100644 --- a/tests/unit-tests/IterableApiCriteriaFetchTests.swift +++ b/tests/unit-tests/IterableApiCriteriaFetchTests.swift @@ -61,8 +61,8 @@ class IterableApiCriteriaFetchTests: XCTestCase { // Manually trigger the criteria fetch logic that happens in initialize2() but not in initializeForTesting() if let implementation = IterableAPI.implementation, config.enableUnknownUserActivation, !implementation.isSDKInitialized(), implementation.getVisitorUsageTracked() { - implementation.unknownUserManager.getUnknownUserCriteria() - implementation.unknownUserManager.updateUnknownUserSession() + implementation.unknownUserManager.getUnknownCriteria() + implementation.unknownUserManager.updateUnknownSession() } internalApi = InternalIterableAPI.initializeForTesting( @@ -149,8 +149,8 @@ class IterableApiCriteriaFetchTests: XCTestCase { if let implementation = IterableAPI.implementation, config.enableUnknownUserActivation, !implementation .isSDKInitialized(), implementation .getVisitorUsageTracked() { - implementation.unknownUserManager.getUnknownUserCriteria() - implementation.unknownUserManager.updateUnknownUserSession() + implementation.unknownUserManager.getUnknownCriteria() + implementation.unknownUserManager.updateUnknownSession() } internalApi = InternalIterableAPI.initializeForTesting( diff --git a/tests/unit-tests/UUANormalizationMigrationTests.swift b/tests/unit-tests/UUANormalizationMigrationTests.swift new file mode 100644 index 000000000..fbb84ca25 --- /dev/null +++ b/tests/unit-tests/UUANormalizationMigrationTests.swift @@ -0,0 +1,146 @@ +// +// UUANormalizationMigrationTests.swift +// swift-sdk +// +// Coverage for SDK-412 (Unknown User Activation naming normalization): +// on-disk format migrations + public API deprecated alias forwarding. +// + +import XCTest + +@testable import IterableSDK + +class UUANormalizationMigrationTests: XCTestCase { + + private static let suiteName = "uua.normalization.tests" + + private var userDefaults: UserDefaults! + private var serviceName: String! + + override func setUpWithError() throws { + userDefaults = UserDefaults(suiteName: Self.suiteName) + userDefaults.removePersistentDomain(forName: Self.suiteName) + serviceName = "test-uua-\(UUID().uuidString)" + } + + override func tearDownWithError() throws { + userDefaults.removePersistentDomain(forName: Self.suiteName) + KeychainWrapper(serviceName: serviceName).removeAll() + } + + // MARK: - Keychain key migration: itbl_userid_unknown_user -> itbl_userid_unknown + + func testKeychainUnknownUserIdMigratesFromLegacyKeyOnRead() throws { + let wrapper = KeychainWrapper(serviceName: serviceName) + let legacyValue = "legacy-unknown-user-id" + XCTAssertTrue(wrapper.set(legacyValue.data(using: .utf8)!, + forKey: Const.Keychain.Key.legacyUserIdUnknownUser)) + + let keychain = IterableKeychain(wrapper: wrapper) + + XCTAssertEqual(keychain.userIdUnknownUser, legacyValue) + // After read, legacy should be cleaned up and new key populated. + XCTAssertNil(wrapper.data(forKey: Const.Keychain.Key.legacyUserIdUnknownUser)) + XCTAssertEqual(String(data: wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser)!, encoding: .utf8), + legacyValue) + } + + func testKeychainUnknownUserIdPrefersNewKeyOverLegacy() throws { + let wrapper = KeychainWrapper(serviceName: serviceName) + XCTAssertTrue(wrapper.set("new".data(using: .utf8)!, forKey: Const.Keychain.Key.userIdUnknownUser)) + XCTAssertTrue(wrapper.set("legacy".data(using: .utf8)!, forKey: Const.Keychain.Key.legacyUserIdUnknownUser)) + + let keychain = IterableKeychain(wrapper: wrapper) + XCTAssertEqual(keychain.userIdUnknownUser, "new") + } + + // MARK: - UserDefaults sessions blob: itbl_unknown_user_sessions -> itbl_unknown_sessions + + func testSessionsBlobMigratesFromLegacyUserDefaultsKey() throws { + let payload = #"{"itbl_unknown_user_sessions":{"totalUnknownUserSessionCount":7,"lastUnknownUserSession":2,"firstUnknownUserSession":1}}"# + .data(using: .utf8)! + userDefaults.set(payload, forKey: Const.UserDefault.legacyUnknownUserSessions) + + let defaults = IterableUserDefaults(userDefaults: userDefaults) + let sessions = defaults.unknownUserSessions + + XCTAssertNotNil(sessions) + XCTAssertEqual(sessions?.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 7) + XCTAssertEqual(sessions?.itbl_unknown_user_sessions.lastUnknownUserSession, 2) + XCTAssertEqual(sessions?.itbl_unknown_user_sessions.firstUnknownUserSession, 1) + + XCTAssertNil(userDefaults.data(forKey: Const.UserDefault.legacyUnknownUserSessions)) + XCTAssertNotNil(userDefaults.data(forKey: Const.UserDefault.unknownUserSessions)) + } + + // MARK: - Sessions wrapper CodingKeys: decodes legacy + new, encodes new only + + func testSessionsWrapperDecodesLegacyAndNewKeys() throws { + let decoder = JSONDecoder() + let legacy = #"{"itbl_unknown_user_sessions":{"totalUnknownUserSessionCount":3,"lastUnknownUserSession":222,"firstUnknownUserSession":111}}"# + .data(using: .utf8)! + let modern = #"{"itbl_unknown_sessions":{"totalUnknownSessionCount":3,"lastUnknownSession":222,"firstUnknownSession":111}}"# + .data(using: .utf8)! + + let fromLegacy = try decoder.decode(IterableUnknownUserSessionsWrapper.self, from: legacy) + let fromModern = try decoder.decode(IterableUnknownUserSessionsWrapper.self, from: modern) + + XCTAssertEqual(fromLegacy.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 3) + XCTAssertEqual(fromModern.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 3) + } + + func testSessionsWrapperEncodesNewKeysOnly() throws { + let sessions = IterableUnknownUserSessionsWrapper( + itbl_unknown_user_sessions: IterableUnknownUserSessions( + totalUnknownUserSessionCount: 5, + lastUnknownUserSession: 22, + firstUnknownUserSession: 11 + ) + ) + let data = try JSONEncoder().encode(sessions) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertNotNil(json["itbl_unknown_sessions"]) + XCTAssertNil(json["itbl_unknown_user_sessions"]) + + let inner = try XCTUnwrap(json["itbl_unknown_sessions"] as? [String: Any]) + XCTAssertEqual(inner["totalUnknownSessionCount"] as? Int, 5) + XCTAssertEqual(inner["lastUnknownSession"] as? Int, 22) + XCTAssertEqual(inner["firstUnknownSession"] as? Int, 11) + XCTAssertNil(inner["totalUnknownUserSessionCount"]) + XCTAssertNil(inner["lastUnknownUserSession"]) + XCTAssertNil(inner["firstUnknownUserSession"]) + } + + // MARK: - Stored event discriminator: dataType -> eventType + + func testUnknownUserEventsRewritesLegacyDataTypeKeyOnRead() throws { + let legacyEvent: [[String: Any]] = [ + ["dataType": EventType.customEvent, "eventName": "viewedProduct"], + ["dataType": EventType.purchase, "total": "9.99"] + ] + userDefaults.set(legacyEvent, forKey: Const.UserDefault.unknownUserEvents) + + let defaults = IterableUserDefaults(userDefaults: userDefaults) + let events = try XCTUnwrap(defaults.unknownUserEvents) + + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0][JsonKey.eventType] as? String, EventType.customEvent) + XCTAssertNil(events[0][JsonKey.legacyEventType]) + XCTAssertEqual(events[1][JsonKey.eventType] as? String, EventType.purchase) + + // Migrated payload should be persisted back under the same key. + let stored = try XCTUnwrap(userDefaults.array(forKey: Const.UserDefault.unknownUserEvents) as? [[AnyHashable: Any]]) + XCTAssertEqual(stored[0][JsonKey.eventType] as? String, EventType.customEvent) + XCTAssertNil(stored[0][JsonKey.legacyEventType]) + } + + // MARK: - Identity resolution deprecated alias + + func testIdentityResolutionLegacyInitForwardsToNewName() { + let resolution = IterableIdentityResolution(replayOnVisitorToKnown: true, + mergeOnUnknownUserToKnown: false) + XCTAssertEqual(resolution.mergeOnUnknownToKnown, false) + XCTAssertEqual(resolution.mergeOnUnknownUserToKnown, false) + } +} diff --git a/tests/unit-tests/UserMergeScenariosTests.swift b/tests/unit-tests/UserMergeScenariosTests.swift index c9ddaf469..485ad5e58 100644 --- a/tests/unit-tests/UserMergeScenariosTests.swift +++ b/tests/unit-tests/UserMergeScenariosTests.swift @@ -146,7 +146,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected events to be logged but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: false) IterableAPI.setUserId("testuser123", nil, identityResolution) if let userId = IterableAPI.userId { XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") @@ -192,7 +192,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected events to be logged but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownToKnown: false) IterableAPI.setUserId("testuser123", nil, identityResolution) if let userId = IterableAPI.userId { @@ -241,7 +241,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected events to be logged but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownToKnown: true) IterableAPI.setUserId("testuser123", nil, identityResolution) if let userId = IterableAPI.userId { @@ -327,7 +327,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected unknown user but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: false) IterableAPI.setUserId("testuser123", nil, identityResolution) // Verify "merge user" API call is not made @@ -363,7 +363,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected unknown user nil but found") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) IterableAPI.setUserId("testuser123", nil, identityResolution) waitForDuration(seconds: 3) @@ -460,7 +460,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTAssertNil(localStorage.userIdUnknownUser, "Expected unknown user to be nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: false) IterableAPI.setUserId("testuseranotheruser", nil, identityResolution) if let userId = IterableAPI.userId { @@ -511,7 +511,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) IterableAPI.setUserId("testuseranotheruser", nil, identityResolution) waitForDuration(seconds: 3) @@ -597,7 +597,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected events to be logged but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: false) IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) if let userId = IterableAPI.email { XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") @@ -642,7 +642,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected events to be logged but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownToKnown: false) IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) if let userId = IterableAPI.email { XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") @@ -689,7 +689,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected events to be logged but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownToKnown: true) IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) if let userId = IterableAPI.email { XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") @@ -772,7 +772,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected unknown user but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: false) IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) // Verify "merge user" API call is not made @@ -807,7 +807,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTFail("Expected unknown user but found nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) // Verify "merge user" API call is made @@ -900,7 +900,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: false) IterableAPI.setEmail("testuseranotheruser@test.com", nil, identityResolution) if let userId = IterableAPI.email { XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") @@ -950,7 +950,7 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") } - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) IterableAPI.setEmail("testuseranotheruser@test.com", nil, identityResolution) waitForDuration(seconds: 3) @@ -1002,12 +1002,12 @@ class UserMergeScenariosTests: XCTestCase, AuthProvider { } // Verify that unknown user session request was made exactly once - let unknownUserSessionRequest = mockSession.getRequest(withEndPoint: Const.Path.trackUnknownUserSession) + let unknownUserSessionRequest = mockSession.getRequest(withEndPoint: Const.Path.trackUnknownSession) XCTAssertNotNil(unknownUserSessionRequest, "Unknown user session request should not be nil") // Count total requests with unknown user session endpoint let unknownUserSessionRequests = mockSession.requests.filter { request in - request.url?.absoluteString.contains(Const.Path.trackUnknownUserSession) == true + request.url?.absoluteString.contains(Const.Path.trackUnknownSession) == true } XCTAssertEqual(unknownUserSessionRequests.count, 1, "Unknown user session should be called exactly once") diff --git a/tests/unit-tests/ValidateTokenForDestinationUserTest.swift b/tests/unit-tests/ValidateTokenForDestinationUserTest.swift index 80d7eb350..4f2c76649 100644 --- a/tests/unit-tests/ValidateTokenForDestinationUserTest.swift +++ b/tests/unit-tests/ValidateTokenForDestinationUserTest.swift @@ -202,7 +202,7 @@ final class ValidateTokenForDestinationUserTest: XCTestCase { XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.userIdUnknownUserToken) - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) IterableAPI.setUserId(ValidateTokenForDestinationUserTest.userId, nil, identityResolution) // Verify "merge user" API call is made @@ -305,7 +305,7 @@ final class ValidateTokenForDestinationUserTest: XCTestCase { XCTAssertNil(IterableAPI.email) XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.userIdUnknownUserToken) - let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true) IterableAPI.setEmail(ValidateTokenForDestinationUserTest.email, nil, identityResolution) // Verify "merge user" API call is made From 6835999b9b26fe66d3c47d0340d0675f51812bbc Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Wed, 29 Apr 2026 01:43:47 +0100 Subject: [PATCH 2/4] Fixed criteria matching broken by JsonKey.eventType rename --- swift-sdk/Core/Constants.swift | 4 ++++ .../UnknownUserManager+Functions.swift | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index 438c07537..f43ff2e5b 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -236,6 +236,10 @@ enum JsonKey { /// Legacy event-type discriminator key written to local storage prior to UUA naming /// normalization. Read for one-shot migration only. static let legacyEventType = "dataType" + /// Criteria payload field (from `/unknownuser/list`) that specifies which event type + /// a criteria applies to. Distinct from ``eventType``; happens to share the same string + /// as ``legacyEventType``, but is a permanent backend contract, not a storage migration. + static let criteriaDataType = "dataType" static let eventTimeStamp = "eventTimeStamp" static let criteriaSets = "criteriaSets" static let matchedCriteriaId = "matchedCriteriaId" diff --git a/swift-sdk/Internal/UnknownUserManager+Functions.swift b/swift-sdk/Internal/UnknownUserManager+Functions.swift index dc3c0d443..7254f98f0 100644 --- a/swift-sdk/Internal/UnknownUserManager+Functions.swift +++ b/swift-sdk/Internal/UnknownUserManager+Functions.swift @@ -56,7 +56,19 @@ func getUTCDateTime() -> String { struct CriteriaCompletionChecker { init(unknownUserCriteria: Data, unknownUserEvents: [[AnyHashable: Any]]) { - self.unknownUserEvents = unknownUserEvents + self.unknownUserEvents = unknownUserEvents.map { event in + // Defensive: events may still carry the legacy `dataType` discriminator + // (e.g. payloads not routed through `IterableUserDefaults`'s normalizer, + // or fixtures constructed in tests). Promote to `eventType` so the + // matcher's stored-event reads work consistently. + if event[JsonKey.eventType] == nil, let legacy = event[JsonKey.legacyEventType] { + var normalized = event + normalized[JsonKey.eventType] = legacy + normalized.removeValue(forKey: JsonKey.legacyEventType) + return normalized + } + return event + } self.unknownUserCriteria = unknownUserCriteria } @@ -241,7 +253,7 @@ struct CriteriaCompletionChecker { var mutableNode = node for (index, eventData) in localEventData.enumerated() { guard let trackingType = eventData[JsonKey.eventType] as? String else { continue } - let dataType = mutableNode[JsonKey.eventType] as? String + let dataType = mutableNode[JsonKey.criteriaDataType] as? String if eventData[JsonKey.CriteriaItem.criteriaId] == nil && dataType == trackingType { if let searchCombo = mutableNode[JsonKey.CriteriaItem.searchCombo] as? [String: Any] { let searchQueries = searchCombo[JsonKey.CriteriaItem.searchQueries] as? [[AnyHashable: Any]] ?? [] @@ -369,7 +381,7 @@ struct CriteriaCompletionChecker { let matchResult = filteredSearchQueries.allSatisfy { query in let field = query[JsonKey.CriteriaItem.field] as! String var doesKeyExist = false - if let eventType = query[JsonKey.eventType] as? String, eventType == EventType.customEvent, let fieldType = query[JsonKey.CriteriaItem.fieldType] as? String, fieldType == "object", let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String, comparatorType == JsonKey.CriteriaItem.Comparator.IsSet, let eventName = eventData[JsonKey.eventName] as? String { + if let eventType = query[JsonKey.criteriaDataType] as? String, eventType == EventType.customEvent, let fieldType = query[JsonKey.CriteriaItem.fieldType] as? String, fieldType == "object", let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String, comparatorType == JsonKey.CriteriaItem.Comparator.IsSet, let eventName = eventData[JsonKey.eventName] as? String { if (eventName == EventType.updateCart && field == eventName) || (field == eventName) { return true From 177827dadfcfa90ce4529ff5d7c1d8860111567d Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Wed, 6 May 2026 17:49:26 +0100 Subject: [PATCH 3/4] address review + revert internal-only on-disk renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop new methods from `UnknownUserManagerProtocol` and retype `InternalIterableAPI.unknownUserManager` to the concrete `UnknownUserManager` so internal call sites keep the new names without breaking external conformers (review #3). - Revert the keychain key (`itbl_userid_unknown_user`) and the UserDefaults sessions wrapper key (`itbl_unknown_user_sessions`) back to their pre-PR values; both are local-only with no backend contract, so the perpetual migration cost and downgrade footgun weren't worth the cosmetic alignment (review #5). Removes the associated migrations in `IterableKeychain` / `IterableUserDefaults` and the `UserDefaults.standard`-vs-injected-store bug they introduced (review #1, #2). - Keep the inner `IterableUnknownUserSessions` field rename to align with Android per ticket #3 — these names are sent to the backend in `unknownSessionContext`, so cross-SDK alignment matters here. Uses `CodingKeys` for the new payload + a backward-compatible decoder for existing on-disk blobs. - Keep the `dataType → eventType` discriminator rename with one-shot read normalization (ticket #5). - Keep the public `UnknownUserManager` / `IterableIdentityResolution` renames with deprecated forwarders. Tests: wire `UUANormalizationMigrationTests` into the Xcode project (was orphaned), add the missing `unknownUserUpdate` migration test (review #4), add deprecated-forwarder coverage for every renamed `UnknownUserManager` method, and assert the encoded sessions payload uses the Android-aligned field names while still decoding legacy blobs. --- swift-sdk.xcodeproj/project.pbxproj | 4 + swift-sdk/Core/Constants.swift | 8 +- swift-sdk/Internal/InternalIterableAPI.swift | 2 +- swift-sdk/Internal/IterableUserDefaults.swift | 17 +- swift-sdk/Internal/Models.swift | 30 +-- .../Internal/UnknownUserManagerProtocol.swift | 25 +- .../DependencyContainerProtocol.swift | 2 +- .../Utilities/Keychain/IterableKeychain.swift | 21 +- .../UUANormalizationMigrationTests.swift | 245 +++++++++++------- 9 files changed, 182 insertions(+), 172 deletions(-) diff --git a/swift-sdk.xcodeproj/project.pbxproj b/swift-sdk.xcodeproj/project.pbxproj index 19eb58c6e..2a61db74d 100644 --- a/swift-sdk.xcodeproj/project.pbxproj +++ b/swift-sdk.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 18E23AE02C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */; }; 18E5B5D12CC77BCE00A558EC /* IterableTokenGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */; }; 18E5B5D32CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */; }; + BDA4120000000000000DA001 /* UUANormalizationMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA4120000000000000DA002 /* UUANormalizationMigrationTests.swift */; }; 1CBFFE1A2A97AEEF00ED57EE /* EmbeddedManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */; }; 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */; }; 1CBFFE1C2A97AEEF00ED57EE /* EmbeddedSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */; }; @@ -595,6 +596,7 @@ 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CombinationLogicEventTypeCriteria.swift; sourceTree = ""; }; 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableTokenGenerator.swift; sourceTree = ""; }; 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateTokenForDestinationUserTest.swift; sourceTree = ""; }; + BDA4120000000000000DA002 /* UUANormalizationMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUANormalizationMigrationTests.swift; sourceTree = ""; }; 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedManagerTests.swift; sourceTree = ""; }; 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedMessagingProcessorTests.swift; sourceTree = ""; }; 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedSessionManagerTests.swift; sourceTree = ""; }; @@ -1744,6 +1746,7 @@ 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */, 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */, 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */, + BDA4120000000000000DA002 /* UUANormalizationMigrationTests.swift */, ); name = "unknown-user-tracking-tests"; sourceTree = ""; @@ -2422,6 +2425,7 @@ 55CC257B2462064F00A77FD5 /* InAppPresenterTests.swift in Sources */, AC4BA00224163D8F007359F1 /* IterableHtmlMessageViewControllerTests.swift in Sources */, 18E5B5D32CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift in Sources */, + BDA4120000000000000DA001 /* UUANormalizationMigrationTests.swift in Sources */, 55B37FC822975A840042F13A /* InboxMessageViewModelTests.swift in Sources */, 182A2A152C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift in Sources */, 55E6F462238E066400808BCE /* DeepLinkTests.swift in Sources */, diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index f43ff2e5b..97215deb7 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -89,9 +89,7 @@ enum Const { static let unknownUserEvents = "itbl_unknown_user_events" static let unknownUserUpdate = "itbl_unknown_user_update" static let criteriaData = "itbl_criteria_data" - static let unknownUserSessions = "itbl_unknown_sessions" - /// Legacy key used prior to UUA naming normalization. Read for one-shot migration only. - static let legacyUnknownUserSessions = "itbl_unknown_user_sessions" + static let unknownUserSessions = "itbl_unknown_user_sessions" static let matchedCriteria = "itbl_matched_criteria" static let eventList = "itbl_event_list" static let visitorUsageTracked = "itbl_visitor_usage_tracked" @@ -108,9 +106,7 @@ enum Const { enum Key { static let email = "itbl_email" static let userId = "itbl_userid" - static let userIdUnknownUser = "itbl_userid_unknown" - /// Legacy key used prior to UUA naming normalization. Read for one-shot migration only. - static let legacyUserIdUnknownUser = "itbl_userid_unknown_user" + static let userIdUnknownUser = "itbl_userid_unknown_user" static let authToken = "itbl_auth_token" } } diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index 8fe93d85c..e28b9ea01 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -95,7 +95,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { self.dependencyContainer.createAuthManager(config: self.config) }() - lazy var unknownUserManager: UnknownUserManagerProtocol = { + lazy var unknownUserManager: UnknownUserManager = { self.dependencyContainer.createUnknownUserManager(config: self.config) }() diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 82580eb00..2836655e7 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -164,20 +164,8 @@ class IterableUserDefaults { private func unknownUserSessionsData(withKey key: UserDefaultsKey) -> IterableUnknownUserSessionsWrapper? { if let savedData = UserDefaults.standard.data(forKey: key.value) { - return try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: savedData) - } - // One-shot migration from the legacy UserDefaults key, if present. - let legacyKey = UserDefaultsKey.legacyUnknownUserSessions - if let legacyData = UserDefaults.standard.data(forKey: legacyKey.value) { - let decoded = try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: legacyData) - if let decoded = decoded { - if let reEncoded = try? JSONEncoder().encode(decoded) { - userDefaults.set(reEncoded, forKey: key.value) - } - userDefaults.removeObject(forKey: legacyKey.value) - return decoded - } - userDefaults.removeObject(forKey: legacyKey.value) + let decodedData = try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: savedData) + return decodedData } return nil } @@ -396,7 +384,6 @@ class IterableUserDefaults { static let unknownUserUpdate = UserDefaultsKey(value: Const.UserDefault.unknownUserUpdate) static let criteriaData = UserDefaultsKey(value: Const.UserDefault.criteriaData) static let unknownUserSessions = UserDefaultsKey(value: Const.UserDefault.unknownUserSessions) - static let legacyUnknownUserSessions = UserDefaultsKey(value: Const.UserDefault.legacyUnknownUserSessions) static let visitorUsageTracked = UserDefaultsKey(value: Const.UserDefault.visitorUsageTracked) static let visitorConsentTimestamp = UserDefaultsKey(value: Const.UserDefault.visitorConsentTimestamp) diff --git a/swift-sdk/Internal/Models.swift b/swift-sdk/Internal/Models.swift index cf4bc1ea6..dfbc4b200 100644 --- a/swift-sdk/Internal/Models.swift +++ b/swift-sdk/Internal/Models.swift @@ -39,13 +39,17 @@ struct IterableUnknownUserSessions: Codable { var lastUnknownUserSession: Int var firstUnknownUserSession: Int + // Cross-SDK alignment (SDK-412): backend payload uses the de-"User"'d + // names that Android already sends. Swift property names stay put to keep + // call sites unchanged. enum CodingKeys: String, CodingKey { case totalUnknownUserSessionCount = "totalUnknownSessionCount" case lastUnknownUserSession = "lastUnknownSession" case firstUnknownUserSession = "firstUnknownSession" } - // Legacy keys, kept only for one-shot decode migration. + // Legacy keys, kept only so on-disk blobs written by pre-SDK-412 builds + // continue to decode after upgrade. private enum LegacyCodingKeys: String, CodingKey { case totalUnknownUserSessionCount case lastUnknownUserSession @@ -63,12 +67,10 @@ struct IterableUnknownUserSessions: Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let count = try container.decodeIfPresent(Int.self, forKey: .totalUnknownUserSessionCount) { - // New schema branch self.totalUnknownUserSessionCount = count self.lastUnknownUserSession = try container.decode(Int.self, forKey: .lastUnknownUserSession) self.firstUnknownUserSession = try container.decode(Int.self, forKey: .firstUnknownUserSession) } else { - // Fallback to legacy schema with "User" in the keys. let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) self.totalUnknownUserSessionCount = try legacy.decode(Int.self, forKey: .totalUnknownUserSessionCount) self.lastUnknownUserSession = try legacy.decode(Int.self, forKey: .lastUnknownUserSession) @@ -79,26 +81,4 @@ struct IterableUnknownUserSessions: Codable { struct IterableUnknownUserSessionsWrapper: Codable { var itbl_unknown_user_sessions: IterableUnknownUserSessions - - enum CodingKeys: String, CodingKey { - case itbl_unknown_user_sessions = "itbl_unknown_sessions" - } - - private enum LegacyCodingKeys: String, CodingKey { - case itbl_unknown_user_sessions - } - - init(itbl_unknown_user_sessions: IterableUnknownUserSessions) { - self.itbl_unknown_user_sessions = itbl_unknown_user_sessions - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let sessions = try container.decodeIfPresent(IterableUnknownUserSessions.self, forKey: .itbl_unknown_user_sessions) { - self.itbl_unknown_user_sessions = sessions - } else { - let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) - self.itbl_unknown_user_sessions = try legacy.decode(IterableUnknownUserSessions.self, forKey: .itbl_unknown_user_sessions) - } - } } diff --git a/swift-sdk/Internal/UnknownUserManagerProtocol.swift b/swift-sdk/Internal/UnknownUserManagerProtocol.swift index d987d5af0..5627f1ee3 100644 --- a/swift-sdk/Internal/UnknownUserManagerProtocol.swift +++ b/swift-sdk/Internal/UnknownUserManagerProtocol.swift @@ -6,32 +6,15 @@ // import Foundation @objc public protocol UnknownUserManagerProtocol { - func trackUnknownEvent(name: String, dataFields: [AnyHashable: Any]?) - func trackUnknownPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) - func trackUnknownUpdateCart(items: [CommerceItem]) - func trackUnknownTokenRegistration(token: String) - func trackUnknownUpdateUser(_ dataFields: [AnyHashable: Any]) - func updateUnknownSession() - func getLastCriteriaFetch() -> Double - func updateLastCriteriaFetch(currentTime: Double) - func getUnknownCriteria() - func syncEvents() - func clearVisitorEventsAndUserData() - - // MARK: - Deprecated aliases (remove in next major) - - @available(*, deprecated, renamed: "trackUnknownEvent(name:dataFields:)") func trackUnknownUserEvent(name: String, dataFields: [AnyHashable: Any]?) - @available(*, deprecated, renamed: "trackUnknownPurchaseEvent(total:items:dataFields:)") func trackUnknownUserPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) - @available(*, deprecated, renamed: "trackUnknownUpdateCart(items:)") func trackUnknownUserUpdateCart(items: [CommerceItem]) - @available(*, deprecated, renamed: "trackUnknownTokenRegistration(token:)") func trackUnknownUserTokenRegistration(token: String) - @available(*, deprecated, renamed: "trackUnknownUpdateUser(_:)") func trackUnknownUserUpdateUser(_ dataFields: [AnyHashable: Any]) - @available(*, deprecated, renamed: "updateUnknownSession()") func updateUnknownUserSession() - @available(*, deprecated, renamed: "getUnknownCriteria()") + func getLastCriteriaFetch() -> Double + func updateLastCriteriaFetch(currentTime: Double) func getUnknownUserCriteria() + func syncEvents() + func clearVisitorEventsAndUserData() } diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index 881bb31a5..61337fc3d 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -145,7 +145,7 @@ extension DependencyContainerProtocol { RedirectNetworkSession(delegate: delegate) } - func createUnknownUserManager(config: IterableConfig) -> UnknownUserManagerProtocol { + func createUnknownUserManager(config: IterableConfig) -> UnknownUserManager { UnknownUserManager(config:config, localStorage: localStorage, dateProvider: dateProvider, diff --git a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift index 540c7cbdd..981cc5028 100644 --- a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift +++ b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift @@ -101,30 +101,19 @@ class IterableKeychain { var userIdUnknownUser: String? { get { - if let data = wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser), - let value = String(data: data, encoding: .utf8) { - return value - } - // One-shot migration from the legacy key used prior to UUA naming normalization. - if let legacyData = wrapper.data(forKey: Const.Keychain.Key.legacyUserIdUnknownUser), - let legacyValue = String(data: legacyData, encoding: .utf8) { - wrapper.set(legacyData, forKey: Const.Keychain.Key.userIdUnknownUser) - wrapper.removeValue(forKey: Const.Keychain.Key.legacyUserIdUnknownUser) - return legacyValue - } - return nil + let data = wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser) + + return data.flatMap { String(data: $0, encoding: .utf8) } } - + set { guard let token = newValue, let data = token.data(using: .utf8) else { wrapper.removeValue(forKey: Const.Keychain.Key.userIdUnknownUser) - wrapper.removeValue(forKey: Const.Keychain.Key.legacyUserIdUnknownUser) return } - + wrapper.set(data, forKey: Const.Keychain.Key.userIdUnknownUser) - wrapper.removeValue(forKey: Const.Keychain.Key.legacyUserIdUnknownUser) } } diff --git a/tests/unit-tests/UUANormalizationMigrationTests.swift b/tests/unit-tests/UUANormalizationMigrationTests.swift index fbb84ca25..efa98f4f6 100644 --- a/tests/unit-tests/UUANormalizationMigrationTests.swift +++ b/tests/unit-tests/UUANormalizationMigrationTests.swift @@ -3,7 +3,8 @@ // swift-sdk // // Coverage for SDK-412 (Unknown User Activation naming normalization): -// on-disk format migrations + public API deprecated alias forwarding. +// stored-event discriminator migration (`dataType` -> `eventType`), public +// API deprecated alias forwarding, and `IterableIdentityResolution` alias. // import XCTest @@ -15,132 +16,202 @@ class UUANormalizationMigrationTests: XCTestCase { private static let suiteName = "uua.normalization.tests" private var userDefaults: UserDefaults! - private var serviceName: String! override func setUpWithError() throws { userDefaults = UserDefaults(suiteName: Self.suiteName) userDefaults.removePersistentDomain(forName: Self.suiteName) - serviceName = "test-uua-\(UUID().uuidString)" } override func tearDownWithError() throws { userDefaults.removePersistentDomain(forName: Self.suiteName) - KeychainWrapper(serviceName: serviceName).removeAll() } - // MARK: - Keychain key migration: itbl_userid_unknown_user -> itbl_userid_unknown + // MARK: - Stored event discriminator: dataType -> eventType - func testKeychainUnknownUserIdMigratesFromLegacyKeyOnRead() throws { - let wrapper = KeychainWrapper(serviceName: serviceName) - let legacyValue = "legacy-unknown-user-id" - XCTAssertTrue(wrapper.set(legacyValue.data(using: .utf8)!, - forKey: Const.Keychain.Key.legacyUserIdUnknownUser)) + func testUnknownUserEventsRewritesLegacyDataTypeKeyOnRead() throws { + let legacyEvent: [[String: Any]] = [ + ["dataType": EventType.customEvent, "eventName": "viewedProduct"], + ["dataType": EventType.purchase, "total": "9.99"] + ] + userDefaults.set(legacyEvent, forKey: Const.UserDefault.unknownUserEvents) + + let defaults = IterableUserDefaults(userDefaults: userDefaults) + let events = try XCTUnwrap(defaults.unknownUserEvents) - let keychain = IterableKeychain(wrapper: wrapper) + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0][JsonKey.eventType] as? String, EventType.customEvent) + XCTAssertNil(events[0][JsonKey.legacyEventType]) + XCTAssertEqual(events[1][JsonKey.eventType] as? String, EventType.purchase) - XCTAssertEqual(keychain.userIdUnknownUser, legacyValue) - // After read, legacy should be cleaned up and new key populated. - XCTAssertNil(wrapper.data(forKey: Const.Keychain.Key.legacyUserIdUnknownUser)) - XCTAssertEqual(String(data: wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser)!, encoding: .utf8), - legacyValue) + let stored = try XCTUnwrap(userDefaults.array(forKey: Const.UserDefault.unknownUserEvents) as? [[AnyHashable: Any]]) + XCTAssertEqual(stored[0][JsonKey.eventType] as? String, EventType.customEvent) + XCTAssertNil(stored[0][JsonKey.legacyEventType]) } - func testKeychainUnknownUserIdPrefersNewKeyOverLegacy() throws { - let wrapper = KeychainWrapper(serviceName: serviceName) - XCTAssertTrue(wrapper.set("new".data(using: .utf8)!, forKey: Const.Keychain.Key.userIdUnknownUser)) - XCTAssertTrue(wrapper.set("legacy".data(using: .utf8)!, forKey: Const.Keychain.Key.legacyUserIdUnknownUser)) + func testUnknownUserUpdateRewritesLegacyDataTypeKeyOnRead() throws { + let legacyUpdate: [String: Any] = [ + "dataType": EventType.updateUser, + "email": "user@example.com", + ] + userDefaults.set(legacyUpdate, forKey: Const.UserDefault.unknownUserUpdate) - let keychain = IterableKeychain(wrapper: wrapper) - XCTAssertEqual(keychain.userIdUnknownUser, "new") - } + let defaults = IterableUserDefaults(userDefaults: userDefaults) + let update = try XCTUnwrap(defaults.unknownUserUpdate) - // MARK: - UserDefaults sessions blob: itbl_unknown_user_sessions -> itbl_unknown_sessions + XCTAssertEqual(update[JsonKey.eventType] as? String, EventType.updateUser) + XCTAssertNil(update[JsonKey.legacyEventType]) - func testSessionsBlobMigratesFromLegacyUserDefaultsKey() throws { - let payload = #"{"itbl_unknown_user_sessions":{"totalUnknownUserSessionCount":7,"lastUnknownUserSession":2,"firstUnknownUserSession":1}}"# - .data(using: .utf8)! - userDefaults.set(payload, forKey: Const.UserDefault.legacyUnknownUserSessions) + let stored = try XCTUnwrap(userDefaults.dictionary(forKey: Const.UserDefault.unknownUserUpdate)) + XCTAssertEqual(stored[JsonKey.eventType] as? String, EventType.updateUser) + XCTAssertNil(stored[JsonKey.legacyEventType]) + } - let defaults = IterableUserDefaults(userDefaults: userDefaults) - let sessions = defaults.unknownUserSessions + func testUnknownUserEventsLeavesModernEventsUntouched() throws { + let modern: [[String: Any]] = [[JsonKey.eventType: EventType.customEvent, "eventName": "foo"]] + userDefaults.set(modern, forKey: Const.UserDefault.unknownUserEvents) - XCTAssertNotNil(sessions) - XCTAssertEqual(sessions?.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 7) - XCTAssertEqual(sessions?.itbl_unknown_user_sessions.lastUnknownUserSession, 2) - XCTAssertEqual(sessions?.itbl_unknown_user_sessions.firstUnknownUserSession, 1) + let defaults = IterableUserDefaults(userDefaults: userDefaults) + let events = try XCTUnwrap(defaults.unknownUserEvents) + XCTAssertEqual(events[0][JsonKey.eventType] as? String, EventType.customEvent) + XCTAssertNil(events[0][JsonKey.legacyEventType]) + } - XCTAssertNil(userDefaults.data(forKey: Const.UserDefault.legacyUnknownUserSessions)) - XCTAssertNotNil(userDefaults.data(forKey: Const.UserDefault.unknownUserSessions)) + func testCriteriaCheckerNormalizesLegacyDataTypeOnInit() { + let events: [[AnyHashable: Any]] = [ + ["dataType": EventType.customEvent, "eventName": "x"] + ] + let checker = CriteriaCompletionChecker(unknownUserCriteria: Data(), + unknownUserEvents: events) + XCTAssertNil(checker.getMatchedCriteria()) + // Indirect: matcher reads work via JsonKey.eventType, so a legacy event + // becomes filterable via the new key. Use the public filter to assert. + let nonCart = checker.getNonCartEvents() + XCTAssertEqual(nonCart.first?[JsonKey.eventType] as? String, EventType.customEvent) + XCTAssertNil(nonCart.first?[JsonKey.legacyEventType]) } - // MARK: - Sessions wrapper CodingKeys: decodes legacy + new, encodes new only + // MARK: - Sessions inner-struct field alignment (SDK-412 #3) + + func testSessionsEncoderUsesAndroidAlignedFieldNames() throws { + let sessions = IterableUnknownUserSessions(totalUnknownUserSessionCount: 5, + lastUnknownUserSession: 22, + firstUnknownUserSession: 11) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(sessions)) as? [String: Any]) + XCTAssertEqual(json["totalUnknownSessionCount"] as? Int, 5) + XCTAssertEqual(json["lastUnknownSession"] as? Int, 22) + XCTAssertEqual(json["firstUnknownSession"] as? Int, 11) + XCTAssertNil(json["totalUnknownUserSessionCount"]) + XCTAssertNil(json["lastUnknownUserSession"]) + XCTAssertNil(json["firstUnknownUserSession"]) + } - func testSessionsWrapperDecodesLegacyAndNewKeys() throws { - let decoder = JSONDecoder() - let legacy = #"{"itbl_unknown_user_sessions":{"totalUnknownUserSessionCount":3,"lastUnknownUserSession":222,"firstUnknownUserSession":111}}"# + func testSessionsDecoderAcceptsLegacyAndModernKeys() throws { + let legacy = #"{"totalUnknownUserSessionCount":3,"lastUnknownUserSession":2,"firstUnknownUserSession":1}"# .data(using: .utf8)! - let modern = #"{"itbl_unknown_sessions":{"totalUnknownSessionCount":3,"lastUnknownSession":222,"firstUnknownSession":111}}"# + let modern = #"{"totalUnknownSessionCount":3,"lastUnknownSession":2,"firstUnknownSession":1}"# .data(using: .utf8)! + let fromLegacy = try JSONDecoder().decode(IterableUnknownUserSessions.self, from: legacy) + let fromModern = try JSONDecoder().decode(IterableUnknownUserSessions.self, from: modern) + XCTAssertEqual(fromLegacy.totalUnknownUserSessionCount, 3) + XCTAssertEqual(fromLegacy.lastUnknownUserSession, 2) + XCTAssertEqual(fromLegacy.firstUnknownUserSession, 1) + XCTAssertEqual(fromModern.totalUnknownUserSessionCount, 3) + XCTAssertEqual(fromModern.lastUnknownUserSession, 2) + XCTAssertEqual(fromModern.firstUnknownUserSession, 1) + } - let fromLegacy = try decoder.decode(IterableUnknownUserSessionsWrapper.self, from: legacy) - let fromModern = try decoder.decode(IterableUnknownUserSessionsWrapper.self, from: modern) + // MARK: - Identity resolution deprecated alias - XCTAssertEqual(fromLegacy.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 3) - XCTAssertEqual(fromModern.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 3) + func testIdentityResolutionLegacyInitForwardsToNewName() { + let resolution = IterableIdentityResolution(replayOnVisitorToKnown: true, + mergeOnUnknownUserToKnown: false) + XCTAssertEqual(resolution.mergeOnUnknownToKnown, false) + XCTAssertEqual(resolution.mergeOnUnknownUserToKnown, false) } - func testSessionsWrapperEncodesNewKeysOnly() throws { - let sessions = IterableUnknownUserSessionsWrapper( - itbl_unknown_user_sessions: IterableUnknownUserSessions( - totalUnknownUserSessionCount: 5, - lastUnknownUserSession: 22, - firstUnknownUserSession: 11 - ) - ) - let data = try JSONEncoder().encode(sessions) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + func testIdentityResolutionDesignatedInitSetsBothAccessors() { + let resolution = IterableIdentityResolution(replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: true) + XCTAssertEqual(resolution.replayOnVisitorToKnown, false) + XCTAssertEqual(resolution.mergeOnUnknownToKnown, true) + XCTAssertEqual(resolution.mergeOnUnknownUserToKnown, true) + } - XCTAssertNotNil(json["itbl_unknown_sessions"]) - XCTAssertNil(json["itbl_unknown_user_sessions"]) + // MARK: - UnknownUserManager deprecated forwarders - let inner = try XCTUnwrap(json["itbl_unknown_sessions"] as? [String: Any]) - XCTAssertEqual(inner["totalUnknownSessionCount"] as? Int, 5) - XCTAssertEqual(inner["lastUnknownSession"] as? Int, 22) - XCTAssertEqual(inner["firstUnknownSession"] as? Int, 11) - XCTAssertNil(inner["totalUnknownUserSessionCount"]) - XCTAssertNil(inner["lastUnknownUserSession"]) - XCTAssertNil(inner["firstUnknownUserSession"]) + private func makeManager(storage: MockLocalStorage = MockLocalStorage()) -> (UnknownUserManager, MockLocalStorage) { + let config = IterableConfig() + config.enableUnknownUserActivation = true + let mgr = UnknownUserManager(config: config, + localStorage: storage, + dateProvider: MockDateProvider(), + notificationStateProvider: MockNotificationStateProvider(enabled: false)) + return (mgr, storage) } - // MARK: - Stored event discriminator: dataType -> eventType + @available(*, deprecated) + func testDeprecatedTrackUnknownUserEventForwards() { + let (mgr, storage) = makeManager() + mgr.trackUnknownUserEvent(name: "viewed", dataFields: ["k": "v"]) + XCTAssertEqual(storage.unknownUserEvents?.count, 1) + } - func testUnknownUserEventsRewritesLegacyDataTypeKeyOnRead() throws { - let legacyEvent: [[String: Any]] = [ - ["dataType": EventType.customEvent, "eventName": "viewedProduct"], - ["dataType": EventType.purchase, "total": "9.99"] - ] - userDefaults.set(legacyEvent, forKey: Const.UserDefault.unknownUserEvents) + @available(*, deprecated) + func testDeprecatedTrackUnknownUserPurchaseEventForwards() { + let (mgr, storage) = makeManager() + mgr.trackUnknownUserPurchaseEvent(total: 10, items: [], dataFields: nil) + XCTAssertEqual(storage.unknownUserEvents?.count, 1) + } - let defaults = IterableUserDefaults(userDefaults: userDefaults) - let events = try XCTUnwrap(defaults.unknownUserEvents) + @available(*, deprecated) + func testDeprecatedTrackUnknownUserUpdateCartForwards() { + let (mgr, storage) = makeManager() + mgr.trackUnknownUserUpdateCart(items: []) + XCTAssertEqual(storage.unknownUserEvents?.count, 1) + } - XCTAssertEqual(events.count, 2) - XCTAssertEqual(events[0][JsonKey.eventType] as? String, EventType.customEvent) - XCTAssertNil(events[0][JsonKey.legacyEventType]) - XCTAssertEqual(events[1][JsonKey.eventType] as? String, EventType.purchase) + @available(*, deprecated) + func testDeprecatedTrackUnknownUserTokenRegistrationForwards() { + let (mgr, storage) = makeManager() + mgr.trackUnknownUserTokenRegistration(token: "tok") + XCTAssertEqual(storage.unknownUserEvents?.count, 1) + } - // Migrated payload should be persisted back under the same key. - let stored = try XCTUnwrap(userDefaults.array(forKey: Const.UserDefault.unknownUserEvents) as? [[AnyHashable: Any]]) - XCTAssertEqual(stored[0][JsonKey.eventType] as? String, EventType.customEvent) - XCTAssertNil(stored[0][JsonKey.legacyEventType]) + @available(*, deprecated) + func testDeprecatedTrackUnknownUserUpdateUserForwards() { + let (mgr, storage) = makeManager() + mgr.trackUnknownUserUpdateUser(["foo": "bar"]) + XCTAssertNotNil(storage.unknownUserUpdate) } - // MARK: - Identity resolution deprecated alias + @available(*, deprecated) + func testDeprecatedUpdateUnknownUserSessionForwards() { + let (mgr, storage) = makeManager() + mgr.updateUnknownUserSession() + XCTAssertEqual(storage.unknownUserSessions?.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 1) + } - func testIdentityResolutionLegacyInitForwardsToNewName() { - let resolution = IterableIdentityResolution(replayOnVisitorToKnown: true, - mergeOnUnknownUserToKnown: false) - XCTAssertEqual(resolution.mergeOnUnknownToKnown, false) - XCTAssertEqual(resolution.mergeOnUnknownUserToKnown, false) + @available(*, deprecated) + func testDeprecatedGetUnknownUserCriteriaForwards() { + let (mgr, _) = makeManager() + mgr.getUnknownUserCriteria() + XCTAssertGreaterThan(mgr.getLastCriteriaFetch(), 0) + } + + func testUpdateUnknownSessionIncrementsExistingSessions() { + let (mgr, storage) = makeManager() + mgr.updateUnknownSession() + mgr.updateUnknownSession() + XCTAssertEqual(storage.unknownUserSessions?.itbl_unknown_user_sessions.totalUnknownUserSessionCount, 2) + } + + func testClearVisitorEventsAndUserDataWipesStorage() { + let (mgr, storage) = makeManager() + mgr.trackUnknownEvent(name: "x", dataFields: nil) + mgr.updateUnknownSession() + mgr.clearVisitorEventsAndUserData() + XCTAssertNil(storage.unknownUserEvents) + XCTAssertNil(storage.unknownUserSessions) + XCTAssertNil(storage.unknownUserUpdate) } } From 04d8903ad93ba6c0a96ab98c45f133ff0ab3644d Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Wed, 17 Jun 2026 01:36:21 +0100 Subject: [PATCH 4/4] Update CHANGELOG for fixes and changes in release Updated CHANGELOG to reflect fixes and changes in the latest release, including a crash fix for CocoaPods and changes to UUA storage encoding. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0342437..800917918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fixed a crash showing out-of-the-box embedded messages on CocoaPods. `IterableEmbeddedView.xib` moved out of `Resources/` in 6.5.9, but the podspec only bundled `Resources/`, so the nib was missing from the CocoaPods resource bundle and `loadViewFromNib()` crashed. The podspec now bundles the UI component XIBs too. SPM was not affected. +### Changed +- Upgrade-then-downgrade hazard on UUA storage: the sessions blob in `UserDefaults` now encodes with `totalUnknownSessionCount` / `lastUnknownSession` / `firstUnknownSession`, and stored UUA events use `eventType` as the type discriminator. A customer who installs an SDK build with this change and later rolls back to a pre-SDK-412 build will hit a decode failure on the sessions blob (unknown user session counter resets to zero) and stored UUA events will be skipped on flush since the older SDK looks for `dataType`. Limited blast radius, but worth flagging for customers who pin or roll back versions. + + ## [6.7.2] ### Added - Added optional `shouldLog(level:) -> Bool` to `IterableLogDelegate`. Defaults to `true` when not implemented. Lets the delegate short-circuit log calls before any message formatting work happens. `DefaultLogDelegate` routes its `minLogLevel` filter through this, and `NoneLogDelegate` returns `false` for every level.