@@ -133,6 +133,118 @@ 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. releaseHeldResponses ( )
184+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
185+
186+ XCTAssertEqual ( client. startedRequests. count, 2 , " Pending follow-up should send after in-flight completes " )
187+ let secondPayload = try XCTUnwrap (
188+ ( client. startedRequests [ 1 ] as? OSRequestUpdateSubscription ) ? . parameters ? [ " subscription " ] as? [ String : Any ]
189+ )
190+ XCTAssertEqual ( secondPayload [ " notification_types " ] as? Int , subscribedNotificationTypes)
191+ XCTAssertEqual ( secondPayload [ " enabled " ] as? Bool , true )
192+ }
193+
194+ /**
195+ A retryable failure (e.g. 500/timeout) must not leave the single-flight gate locked.
196+ The failed request becomes resendable and later updates for the model still go out.
197+ */
198+ func testRetryableFailureDoesNotBlockSubsequentUpdates( ) throws {
199+ let client = MockOneSignalClient ( )
200+ client. fireSuccessForAllRequests = true
201+ OneSignalCoreImpl . setSharedClient ( client)
202+
203+ let executor = OSSubscriptionOperationExecutor ( newRecordsState: OSNewRecordsState ( ) )
204+ let model = makePushSubscriptionModel ( notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId)
205+ let identityModelId = UUID ( ) . uuidString
206+
207+ // Fail the first update with a retryable error (mock responses are keyed by request description).
208+ let requestKey = " OSRequestUpdateSubscription with model: \( model. modelId) "
209+ client. setMockFailureResponseForRequest (
210+ request: requestKey,
211+ error: OneSignalClientError ( code: 500 , message: " retryable " , responseHeaders: nil , response: nil , underlyingError: nil )
212+ )
213+
214+ executor. enqueueDelta ( OSDelta (
215+ name: OS_UPDATE_SUBSCRIPTION_DELTA,
216+ identityModelId: identityModelId,
217+ model: model,
218+ property: " notificationTypes " ,
219+ value: promptedNeverAnswered
220+ ) )
221+ executor. processDeltaQueue ( inBackground: false )
222+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
223+
224+ XCTAssertEqual ( client. executedRequests. count, 1 , " First update should have been attempted and failed retryably " )
225+
226+ // Server recovers; user accepts permission.
227+ client. setMockResponseForRequest ( request: requestKey, response: [ : ] )
228+ model. notificationTypes = subscribedNotificationTypes
229+ XCTAssertTrue ( model. enabled)
230+
231+ executor. enqueueDelta ( OSDelta (
232+ name: OS_UPDATE_SUBSCRIPTION_DELTA,
233+ identityModelId: identityModelId,
234+ model: model,
235+ property: " notificationTypes " ,
236+ value: subscribedNotificationTypes
237+ ) )
238+ executor. processDeltaQueue ( inBackground: false )
239+ OneSignalCoreMocks . waitForBackgroundThreads ( seconds: 0.5 )
240+
241+ let updateRequests = client. executedRequests. compactMap { $0 as? OSRequestUpdateSubscription }
242+ XCTAssertEqual ( updateRequests. count, 2 , " Follow-up update must still send after a retryable failure " )
243+ let lastPayload = try XCTUnwrap ( updateRequests. last? . parameters ? [ " subscription " ] as? [ String : Any ] )
244+ XCTAssertEqual ( lastPayload [ " notification_types " ] as? Int , subscribedNotificationTypes)
245+ XCTAssertEqual ( lastPayload [ " enabled " ] as? Bool , true )
246+ }
247+
136248 // MARK: - Helpers
137249
138250 private func makePushSubscriptionModel( notificationTypes: Int , subscriptionId: String ? ) -> OSSubscriptionModel {
0 commit comments