From 2c267bf839f10e7a334dd7d053c4c7b541c9463a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:53:25 -0700 Subject: [PATCH 1/2] feat: [PR5] bind each Delta to the user that owns it A Delta records a change to a model but not who it was for, so anything built from one had to ask who the current user is. Between the change and the flush the app may have logged in as somebody else, and the queued work was then sent for whoever happened to be current. OSDelta now carries the external ID of the user it was made for, as a required argument so no call site can leave it out. The model store listeners take the operation repo by injection rather than reaching for the singleton, which is also what lets the next PR give the repo an Identity Verification-aware instance. Nothing reads the new field yet; the request pipeline picks it up next. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../OneSignalOSCore/Source/OSDelta.swift | 7 +- .../Source/OSModelStoreListener.swift | 11 +- .../OneSignalOSCoreTests/OSDeltaTests.swift | 74 ++++++ .../OSOperationRepoFlushTests.swift | 1 + .../Source/OSIdentityModelStoreListener.swift | 10 +- .../OSPropertiesModelStoreListener.swift | 8 +- .../OSSubscriptionModelStoreListener.swift | 18 +- .../Source/OneSignalUserManagerImpl.swift | 14 +- .../DeltaOwnershipTests.swift | 212 ++++++++++++++++++ .../OSCustomEventsExecutorTests.swift | 1 + .../SubscriptionUpdateRaceTests.swift | 6 + .../UserConcurrencyTests.swift | 12 +- 13 files changed, 357 insertions(+), 25 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift 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..664d3de77 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? { @@ -51,9 +53,11 @@ class OSPropertiesModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property) or no user instance") 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..8b800b5f4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift @@ -31,9 +31,11 @@ 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? { @@ -41,9 +43,11 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance") 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 ?? "" @@ -58,9 +62,11 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { 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 +84,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..3dcc825d3 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -0,0 +1,212 @@ +/* + 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) + } + + func testSubscriptionAddDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(emailSubscriptionModel())) + + 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) + } + + // 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() From b2a3c449b29dbc95d0235185d8831e1c3cc5d2e3 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 14:14:06 -0700 Subject: [PATCH 2/2] fix: [PR5] drop stale properties/subscription deltas instead of mis-stamping Refuse to stamp the current user when the changed model is no longer theirs so a concurrent login cannot attach the wrong external_id for PR6 auth. Properties require the current properties model; email/SMS add requires the model still be in the store. Remove and push updates keep stamping the current user. Co-authored-by: Cursor --- .../OSPropertiesModelStoreListener.swift | 6 +++-- .../OSSubscriptionModelStoreListener.swift | 8 ++++-- .../DeltaOwnershipTests.swift | 26 ++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift index 664d3de77..ec4d5da5b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift @@ -47,10 +47,12 @@ 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 diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift index 8b800b5f4..4d8224442 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift @@ -39,8 +39,11 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { } 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 @@ -56,6 +59,7 @@ 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 { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index 3dcc825d3..b6fd2de5f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -110,10 +110,24 @@ final class DeltaOwnershipTests: XCTestCase { 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(emailSubscriptionModel())) + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(model)) XCTAssertEqual(delta.externalId, userA) XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) @@ -139,6 +153,16 @@ final class DeltaOwnershipTests: XCTestCase { 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.