@@ -133,6 +133,119 @@ final class SubscriptionUpdateRaceTests: XCTestCase {
133133 XCTAssertEqual ( updateRequests. count, 1 , " Unsent updates for the same subscription should be coalesced " )
134134 }
135135
136+ /**
137+ An UpdateSubscription already on the wire must not race a follow-up PATCH.
138+ The follow-up stays queued until the in-flight request completes, then sends live state.
139+ */
140+ func testInFlightUpdateBlocksFollowUpUntilCompleteThenSendsLiveState( ) throws {
141+ let client = MockOneSignalClient ( )
142+ client. holdResponses = true
143+ client. fireSuccessForAllRequests = true
144+ OneSignalCoreImpl . setSharedClient ( client)
145+
146+ let executor = OSSubscriptionOperationExecutor ( newRecordsState: OSNewRecordsState ( ) )
147+ let model = makePushSubscriptionModel ( notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId)
148+ let identityModelId = UUID ( ) . uuidString
149+
150+ executor. enqueueDelta ( OSDelta (
151+ name: OS_UPDATE_SUBSCRIPTION_DELTA,
152+ identityModelId: identityModelId,
153+ model: model,
154+ property: " notificationTypes " ,
155+ value: promptedNeverAnswered
156+ ) )
157+ executor. processDeltaQueue ( inBackground: false )
158+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.2 )
159+
160+ XCTAssertEqual ( client. startedRequests. count, 1 , " First UpdateSubscription should be in flight " )
161+ let firstPayload = try XCTUnwrap (
162+ ( client. startedRequests [ 0 ] as? OSRequestUpdateSubscription ) ? . parameters ? [ " subscription " ] as? [ String : Any ]
163+ )
164+ XCTAssertEqual ( firstPayload [ " notification_types " ] as? Int , promptedNeverAnswered)
165+ XCTAssertEqual ( firstPayload [ " enabled " ] as? Bool , false )
166+
167+ // Permission granted while first PATCH is still in flight.
168+ model. notificationTypes = subscribedNotificationTypes
169+ XCTAssertTrue ( model. enabled)
170+
171+ executor. enqueueDelta ( OSDelta (
172+ name: OS_UPDATE_SUBSCRIPTION_DELTA,
173+ identityModelId: identityModelId,
174+ model: model,
175+ property: " notificationTypes " ,
176+ value: subscribedNotificationTypes
177+ ) )
178+ executor. processDeltaQueue ( inBackground: false )
179+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.2 )
180+
181+ XCTAssertEqual ( client. startedRequests. count, 1 , " Follow-up must wait for in-flight UpdateSubscription " )
182+
183+ client. holdResponses = false
184+ client. releaseHeldResponses ( )
185+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
186+
187+ XCTAssertEqual ( client. startedRequests. count, 2 , " Pending follow-up should send after in-flight completes " )
188+ let secondPayload = try XCTUnwrap (
189+ ( client. startedRequests [ 1 ] as? OSRequestUpdateSubscription ) ? . parameters ? [ " subscription " ] as? [ String : Any ]
190+ )
191+ XCTAssertEqual ( secondPayload [ " notification_types " ] as? Int , subscribedNotificationTypes)
192+ XCTAssertEqual ( secondPayload [ " enabled " ] as? Bool , true )
193+ }
194+
195+ /**
196+ A retryable failure (e.g. 500/timeout) must not leave the single-flight gate locked.
197+ The failed request becomes resendable and later updates for the model still go out.
198+ */
199+ func testRetryableFailureDoesNotBlockSubsequentUpdates( ) throws {
200+ let client = MockOneSignalClient ( )
201+ client. fireSuccessForAllRequests = true
202+ OneSignalCoreImpl . setSharedClient ( client)
203+
204+ let executor = OSSubscriptionOperationExecutor ( newRecordsState: OSNewRecordsState ( ) )
205+ let model = makePushSubscriptionModel ( notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId)
206+ let identityModelId = UUID ( ) . uuidString
207+
208+ // Fail the first update with a retryable error (mock responses are keyed by request description).
209+ let requestKey = " OSRequestUpdateSubscription with model: \( model. modelId) "
210+ client. setMockFailureResponseForRequest (
211+ request: requestKey,
212+ error: OneSignalClientError ( code: 500 , message: " retryable " , responseHeaders: nil , response: nil , underlyingError: nil )
213+ )
214+
215+ executor. enqueueDelta ( OSDelta (
216+ name: OS_UPDATE_SUBSCRIPTION_DELTA,
217+ identityModelId: identityModelId,
218+ model: model,
219+ property: " notificationTypes " ,
220+ value: promptedNeverAnswered
221+ ) )
222+ executor. processDeltaQueue ( inBackground: false )
223+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
224+
225+ XCTAssertEqual ( client. executedRequests. count, 1 , " First update should have been attempted and failed retryably " )
226+
227+ // Server recovers; user accepts permission.
228+ client. setMockResponseForRequest ( request: requestKey, response: [ : ] )
229+ model. notificationTypes = subscribedNotificationTypes
230+ XCTAssertTrue ( model. enabled)
231+
232+ executor. enqueueDelta ( OSDelta (
233+ name: OS_UPDATE_SUBSCRIPTION_DELTA,
234+ identityModelId: identityModelId,
235+ model: model,
236+ property: " notificationTypes " ,
237+ value: subscribedNotificationTypes
238+ ) )
239+ executor. processDeltaQueue ( inBackground: false )
240+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
241+
242+ let updateRequests = client. executedRequests. compactMap { $0 as? OSRequestUpdateSubscription }
243+ XCTAssertEqual ( updateRequests. count, 2 , " Follow-up update must still send after a retryable failure " )
244+ let lastPayload = try XCTUnwrap ( updateRequests. last? . parameters ? [ " subscription " ] as? [ String : Any ] )
245+ XCTAssertEqual ( lastPayload [ " notification_types " ] as? Int , subscribedNotificationTypes)
246+ XCTAssertEqual ( lastPayload [ " enabled " ] as? Bool , true )
247+ }
248+
136249 // MARK: - Helpers
137250
138251 private func makePushSubscriptionModel( notificationTypes: Int , subscriptionId: String ? ) -> OSSubscriptionModel {
0 commit comments