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