diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 50c5ad2c3..8bea2dbee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -283,6 +283,7 @@ 475F474A2B8E3B4600EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; platformFilter = ios; }; 475F474F2B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47502B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */; }; 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */; }; 5B053FBC2CAE07EB002F30C4 /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */; }; @@ -368,6 +369,7 @@ 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; + 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; 9D348537233C669E00EB81C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D348536233C669E00EB81C9 /* CoreLocation.framework */; }; 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; @@ -1351,6 +1353,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeltaOwnershipTests.swift; sourceTree = ""; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IAMRequestTests.m; sourceTree = ""; }; 3C0EF49D28A1DBCB00E5434B /* OSUserInternalImpl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSUserInternalImpl.swift; sourceTree = ""; }; @@ -1542,6 +1545,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B58E4F3237CE7B3009401E0 /* UIDeviceOverrider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIDeviceOverrider.h; sourceTree = ""; }; @@ -2434,6 +2438,7 @@ 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2612,6 +2617,7 @@ 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, 4795885CE6CFFB1998AC7D09 /* Feature */, 5B47CE0CE255AC7128442FFF /* Jwt */, + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4595,6 +4601,7 @@ B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, + 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4673,6 +4680,7 @@ 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */, 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, + 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift index 9cbba04e3..6feb7eced 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift @@ -36,6 +36,8 @@ open class OSDelta: NSObject, NSCoding { public let deltaId: String public let timestamp: Date public let identityModelId: String + /// The `external_id` of the user this change belongs to, `nil` if that user was anonymous. + public let externalId: String? public var model: OSModel public let property: String public let value: Any @@ -44,11 +46,12 @@ open class OSDelta: NSObject, NSCoding { return "" } - public init(name: String, identityModelId: String, model: OSModel, property: String, value: Any) { + public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any) { self.name = name self.deltaId = UUID().uuidString self.timestamp = Date() self.identityModelId = identityModelId + self.externalId = externalId self.model = model self.property = property self.value = value @@ -59,6 +62,7 @@ open class OSDelta: NSObject, NSCoding { coder.encode(deltaId, forKey: "deltaId") coder.encode(timestamp, forKey: "timestamp") coder.encode(identityModelId, forKey: "identityModelId") + coder.encode(externalId, forKey: "externalId") coder.encode(model, forKey: "model") coder.encode(property, forKey: "property") coder.encode(value, forKey: "value") @@ -81,6 +85,7 @@ open class OSDelta: NSObject, NSCoding { self.deltaId = deltaId self.timestamp = timestamp self.identityModelId = identityModelId + self.externalId = coder.decodeObject(forKey: "externalId") as? String self.model = model self.property = property self.value = value diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift index 540dcca40..3d3c046e4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift @@ -33,7 +33,10 @@ public protocol OSModelStoreListener: OSModelStoreChangedHandler { var store: OSModelStore { get } - init(store: OSModelStore) + // Injected: the shared enqueue below lives in OSCore, which cannot reach the User Manager. + var operationRepo: OSOperationRepo { get } + + init(store: OSModelStore, operationRepo: OSOperationRepo) func getAddModelDelta(_ model: TModel) -> OSDelta? @@ -57,13 +60,13 @@ extension OSModelStoreListener { return } if let delta = getAddModelDelta(addedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } public func onUpdated(_ args: OSModelChangedArgs) { if let delta = getUpdateModelDelta(args) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } @@ -74,7 +77,7 @@ extension OSModelStoreListener { return } if let delta = getRemoveModelDelta(removedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift new file mode 100644 index 000000000..ea887c944 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift @@ -0,0 +1,74 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +@testable import OneSignalOSCore + +/// Covers `OSDelta` archive round trips, including the owning user's external ID. +final class OSDeltaTests: XCTestCase { + + private func makeDelta(externalId: String?) -> OSDelta { + OSDelta( + name: "test_delta", + identityModelId: "identity-model-a", + externalId: externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + ) + } + + private func archiveThenUnarchive(_ delta: OSDelta) throws -> OSDelta { + let data = try NSKeyedArchiver.archivedData(withRootObject: delta, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSDelta) + } + + func testExternalIdSurvivesAnArchiveRoundTrip() throws { + let delta = makeDelta(externalId: "user-a") + + let decoded = try archiveThenUnarchive(delta) + + XCTAssertEqual(decoded.externalId, "user-a") + XCTAssertEqual(decoded.identityModelId, "identity-model-a") + XCTAssertEqual(decoded.deltaId, delta.deltaId) + } + + /// Decode must succeed when externalId is absent, or queued work is dropped on upgrade. + func testADeltaWithoutAnExternalIdStillDecodes() throws { + let delta = makeDelta(externalId: nil) + + let decoded = try archiveThenUnarchive(delta) + + XCTAssertNil(decoded.externalId) + XCTAssertEqual(decoded.identityModelId, "identity-model-a") + XCTAssertEqual(decoded.property, "language") + XCTAssertEqual(decoded.value as? String, "en") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index 875a26ffb..de6859a1d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -133,6 +133,7 @@ final class OSOperationRepoFlushTests: XCTestCase { OSDelta( name: name, identityModelId: UUID().uuidString, + externalId: nil, model: OSModel(changeNotifier: OSEventProducer()), property: property, value: property diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift index 4edabb321..937403966 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSIdentityModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSIdentityModel) -> OSDelta? { @@ -50,6 +52,7 @@ class OSIdentityModelStoreListener: OSModelStoreListener { func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { // TODO: Let users call addAliases with "" IDs? If so, this will change... guard + let identityModel = args.model as? OSIdentityModel, let aliasesDict = args.newValue as? [String: String], let (_, id) = aliasesDict.first else { @@ -60,8 +63,9 @@ class OSIdentityModelStoreListener: OSModelStoreListener { return OSDelta( name: name, - identityModelId: args.model.modelId, - model: args.model, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: identityModel, property: args.property, value: args.newValue ) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift index d1cea8929..ec4d5da5b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSPropertiesModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSPropertiesModel) -> OSDelta? { @@ -45,15 +47,19 @@ class OSPropertiesModelStoreListener: OSModelStoreListener { } func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { + // Drop if this isn't the current user's properties model, the user has switched since guard let _ = OSPropertiesSupportedProperty(rawValue: args.property), - let userInstance = OneSignalUserManagerImpl.sharedInstance._user + let userInstance = OneSignalUserManagerImpl.sharedInstance._user, + userInstance.propertiesModel.modelId == args.model.modelId else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property) or no user instance") + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property), no user instance, or a properties model that is not the current user's") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: args.model, property: args.property, value: args.newValue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift index a51afc244..4d8224442 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift @@ -31,19 +31,26 @@ import OneSignalOSCore class OSSubscriptionModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { - guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance") + // Stale - drop if the model is no longer in this store, the user has switched since + guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user, + store.getModel(modelId: model.modelId) != nil + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance or the model is not in the current store") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_ADD_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: model, property: model.type.rawValue, // push, email, sms value: model.address ?? "" @@ -52,15 +59,18 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { /** The `property` and `value` is not needed for a remove operation, so just pass in some model data as placeholders. + Stamps the current user: `onRemoved` runs after the model has left the store, so membership can't be checked. */ func getRemoveModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getRemoveModelDelta has no user instance") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_REMOVE_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: model, property: model.type.rawValue, // push, email, sms value: model.address ?? "" @@ -78,14 +88,16 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getUpdateModelDelta has no user instance") return nil } - if let onesignalId = userInstance.identityModel.onesignalId { + let identityModel = userInstance.identityModel + if let onesignalId = identityModel.onesignalId { let condition = OSIamFetchReadyCondition.sharedInstance(withId: onesignalId) condition.setSubscriptionUpdatePending(value: true) } return OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: args.model, property: args.property, value: args.newValue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index ca4457d62..f58e983b3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -128,6 +128,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() + // Injected into the model store listeners so a Delta is enqueued against a known repo rather + // than reaching for the singleton. A later PR replaces this with an owned instance. + let operationRepo = OSOperationRepo.sharedInstance + // Shared instances: remote params hydrate them before this class is started, and a // fresh instance here would read none of it. let featureManager = OSFeatureManager.shared @@ -223,10 +227,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } self.identityVerificationService = identityVerificationService self.userJwtRepo = userJwtRepo - self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) - self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) - self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) - self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore) + self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) + self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) + self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) + self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } @@ -663,6 +667,7 @@ extension OneSignalUserManagerImpl { let delta = OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: propertiesModel, property: property.rawValue, value: value @@ -882,6 +887,7 @@ extension OneSignalUserManagerImpl: OSUser { let delta = OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, property: name, value: processedProperties diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift new file mode 100644 index 000000000..b6fd2de5f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -0,0 +1,236 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Deltas and the requests built from them stay bound to the user whose change produced them. +final class DeltaOwnershipTests: XCTestCase { + + private let userA = "user-a" + private let userB = "user-b" + private let emailAddress = "person@example.com" + + private var manager: OneSignalUserManagerImpl { + return OneSignalUserManagerImpl.sharedInstance + } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + // These tests drive the listeners and executors themselves, so keep start() from + // rebuilding the user underneath them. + manager.hasCalledStart = true + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Model store listeners + + func testPropertiesUpdateDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.propertiesModel, property: "tags", newValue: ["tag": "value"]) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Anonymous work must stay stamped nil; filling it in later would attribute it to the wrong user. + func testPropertiesUpdateDeltaFromAnAnonymousUserHasNoExternalId() throws { + let user = newUser(externalId: nil) + + let delta = try XCTUnwrap(manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.propertiesModel, property: "tags", newValue: ["tag": "value"]) + )) + + XCTAssertNil(delta.externalId) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testAliasDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.identityModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.identityModel, property: "aliases", newValue: ["my_alias": "my-alias-id"]) + )) + + XCTAssertEqual(delta.name, OS_ADD_ALIAS_DELTA) + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Stamped from the changed Identity Model, so a post-switch alias change keeps its owner. + func testAliasDeltaIsStampedFromTheChangedIdentityNotTheCurrentUser() throws { + let first = newUser(externalId: userA) + newUser(externalId: userB) + + let delta = try XCTUnwrap(manager.identityModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: first.identityModel, property: "aliases", newValue: ["my_alias": "my-alias-id"]) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) + } + + /// Stale properties model after a switch: drop rather than stamp the new current user. + func testPropertiesUpdateDeltaIsDroppedWhenTheChangedModelIsNotTheCurrentUser() throws { + let first = newUser(externalId: userA) + newUser(externalId: userB) + + let delta = manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: first.propertiesModel, property: "tags", newValue: ["tag": "value"]) + ) + + XCTAssertNil(delta) + } + + func testSubscriptionAddDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + let model = emailSubscriptionModel() + manager.subscriptionModelStore.add(id: emailAddress, model: model, hydrating: true) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(model)) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testSubscriptionRemoveDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getRemoveModelDelta(emailSubscriptionModel())) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testSubscriptionUpdateDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: emailSubscriptionModel(), property: "enabled", newValue: true) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Email/SMS models are cleared on switch but stay subscribed; refuse to stamp the new user. + func testSubscriptionAddDeltaIsDroppedWhenTheChangedModelIsNotInTheCurrentStore() throws { + newUser(externalId: userA) + let model = emailSubscriptionModel() + manager.subscriptionModelStore.add(id: emailAddress, model: model, hydrating: true) + newUser(externalId: userB) + + XCTAssertNil(manager.subscriptionModelStoreListener.getAddModelDelta(model)) + } + + // MARK: - Deltas the User Manager enqueues itself + + /// Session time and custom events skip the model stores, so they are stamped at enqueue. + func testDeltasEnqueuedByTheUserManagerCarryTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + manager.sendSessionTime(100) + manager.trackEvent(name: "test_event", properties: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let sessionDelta = try XCTUnwrap(queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + )) + let eventDelta = try XCTUnwrap(queuedDelta(named: OS_CUSTOM_EVENT_DELTA, property: "test_event")) + + XCTAssertEqual(sessionDelta.externalId, userA) + XCTAssertEqual(sessionDelta.identityModelId, user.identityModel.modelId) + XCTAssertEqual(eventDelta.externalId, userA) + XCTAssertEqual(eventDelta.identityModelId, user.identityModel.modelId) + } + + /// A queued Delta is already owned; a user switch must not rewrite it. + func testAQueuedDeltaKeepsItsOwnerAfterTheCurrentUserChanges() throws { + let first = newUser(externalId: userA) + + manager.sendSessionTime(100) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + newUser(externalId: userB) + + let delta = try XCTUnwrap(queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) + } + + // MARK: - Helpers + + @discardableResult + private func newUser(externalId: String?) -> OSUserInternal { + return manager.setNewInternalUser( + externalId: externalId, + pushSubscriptionModel: OSSubscriptionModel( + type: .push, + address: "", + subscriptionId: UUID().uuidString, + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + ) + } + + private func emailSubscriptionModel() -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: emailAddress, + subscriptionId: "test-subscription-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func queuedDelta(named name: String, property: String) -> OSDelta? { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index 3ea47ff03..12d969d74 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -53,6 +53,7 @@ final class OSCustomEventsExecutorTests: XCTestCase { return OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, property: name, value: properties ?? [:] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 43c1746e9..39ad19648 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -93,6 +93,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -110,6 +111,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -150,6 +152,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -171,6 +174,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -214,6 +218,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -231,6 +236,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index 94d3f4388..ac636e32e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -99,8 +99,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() @@ -138,8 +138,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() @@ -179,8 +179,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue()