@@ -189,4 +189,63 @@ final class OneSignalUserTests: XCTestCase {
189189 contains: expectedPayload)
190190 )
191191 }
192+
193+ /**
194+ Unit test for the tag-merge primitive.
195+
196+ `mergeConfirmedTags` must overlay only the provided keys onto the local model: update existing
197+ values, remove keys whose value is `""`, add new keys, and leave all other (e.g. backend-managed)
198+ tags untouched. It must never wholesale-replace the tag dictionary.
199+ */
200+ func testMergeConfirmedTags_mergesAndRemovesWithoutTouchingOtherTags( ) throws {
201+ /* Setup */
202+ let model = OSPropertiesModel ( changeNotifier: OSEventProducer ( ) )
203+ model. hydrate ( [ " tags " : [ " keep " : " 1 " , " update " : " old " , " remove " : " 2 " ] ] )
204+
205+ /* When */
206+ // "update" changes, "remove" is deleted (""), "add" is new; "keep" must be left untouched
207+ model. mergeConfirmedTags ( [ " update " : " new " , " remove " : " " , " add " : " 3 " ] )
208+
209+ /* Then */
210+ XCTAssertEqual ( model. tags, [ " keep " : " 1 " , " update " : " new " , " add " : " 3 " ] )
211+ }
212+
213+ /**
214+ Regression test: `getTags()` returns `{}` after a concurrent, stale `FetchUser` overwrites the
215+ local tag model.
216+
217+ A concurrent `FetchUser` whose response is missing a recently written tag clears the local tag
218+ cache (`clearUserData()`) and hydrates without it. The `OSRequestUpdateProperties` 202 response
219+ echoes the tags the server just confirmed, so merging those back into the local model must restore
220+ the tags that the stale fetch cleared.
221+ */
222+ func testUpdatePropertiesResponse_restoresTagsClearedByConcurrentFetch( ) throws {
223+ /* Setup */
224+ let client = MockOneSignalClient ( )
225+ MockUserRequests . setDefaultCreateAnonUserResponses ( with: client)
226+ let tags = [ " tag_a " : " value_a " , " tag_b " : " value_b " ]
227+ MockUserRequests . setAddTagsResponse ( with: client, tags: tags)
228+ OneSignalCoreImpl . setSharedClient ( client)
229+
230+ OneSignalUserManagerImpl . sharedInstance. start ( )
231+
232+ // Let the anonymous user be created so it has a OneSignal ID for the update request
233+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
234+
235+ /* When */
236+ // Tags are applied optimistically to the local model and queued as an update request
237+ OneSignalUserManagerImpl . sharedInstance. addTags ( tags)
238+
239+ // Simulate a concurrent, stale FetchUser clearing the local tag cache before the
240+ // UpdateProperties 202 response is processed
241+ OneSignalUserManagerImpl . sharedInstance. user. propertiesModel. clearData ( )
242+ XCTAssertTrue ( OneSignalUserManagerImpl . sharedInstance. getTags ( ) . isEmpty)
243+
244+ // Let the queued UpdateProperties request flush and its 202 echo be processed
245+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 1 )
246+
247+ /* Then */
248+ // The confirmed tags from the 202 response are merged back into the local model
249+ XCTAssertEqual ( OneSignalUserManagerImpl . sharedInstance. getTags ( ) , tags)
250+ }
192251}
0 commit comments