Skip to content

Commit 8ad7af8

Browse files
nan-licursoragent
andcommitted
test: [SDK-4814] add receive-receipt dedup robustness tests
Covers the reload/retry paths the persisted-dedup change relies on: the retry poll skips a reloaded successful marker, an expired marker is re-sent, and a non-retryable failure leaves no marker so a re-emit is re-sent. The executor gains an injectable poll interval and an internal pollPendingRequests solely so the poll path can be driven in tests without the 30s production delay. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3d624c6 commit 8ad7af8

2 files changed

Lines changed: 105 additions & 3 deletions

File tree

iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,11 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
142142

143143
// The live activities request dispatch queue, serial. This synchronizes access to `updateTokens` and `startTokens`.
144144
private var requestDispatch: OSDispatchQueue
145-
private var pollIntervalSeconds = 30
145+
private var pollIntervalSeconds: Int
146146

147-
init(requestDispatch: OSDispatchQueue) {
147+
init(requestDispatch: OSDispatchQueue, pollIntervalSeconds: Int = 30) {
148148
self.requestDispatch = requestDispatch
149+
self.pollIntervalSeconds = pollIntervalSeconds
149150
}
150151

151152
func start() {
@@ -185,7 +186,7 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver {
185186
}
186187
}
187188

188-
private func pollPendingRequests() {
189+
func pollPendingRequests() {
189190
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities pollPendingRequests")
190191

191192
self.requestDispatch.asyncAfterTime(deadline: .now() + .seconds(pollIntervalSeconds)) { [weak self] in

iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,104 @@ final class OSLiveActivitiesExecutorTests: XCTestCase {
478478
XCTAssertTrue(mockClient.executedRequests[0] == request1)
479479
}
480480
}
481+
482+
// Receive-receipt dedup robustness: retry poll, TTL expiry, and non-retryable failure.
483+
extension OSLiveActivitiesExecutorTests {
484+
/**
485+
After relaunch the persisted success marker is reloaded; the retry poll only re-executes unsent
486+
requests, so an already-confirmed notificationId must not be sent again.
487+
*/
488+
func testPollDoesNotResendReloadedSuccessfulReceipt() throws {
489+
/* Setup */
490+
let mockClient = setUpSubscribedUser()
491+
492+
let request = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
493+
mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]())
494+
495+
// First launch: send the receipt so a success marker is persisted.
496+
let firstLaunch = MockDispatchQueue()
497+
let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch)
498+
executor1.append(request)
499+
firstLaunch.waitForDispatches(2)
500+
XCTAssertEqual(mockClient.executedRequests.count, 1)
501+
502+
/* When */
503+
// Relaunch and drive the retry poll directly against the reloaded marker.
504+
let secondLaunch = MockDispatchQueue()
505+
let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch, pollIntervalSeconds: 0)
506+
XCTAssertTrue(executor2.receiveReceipts.items["my-notification-id"]?.requestSuccessful ?? false, "Success marker must survive reload")
507+
executor2.pollPendingRequests()
508+
secondLaunch.waitForDispatches(1)
509+
510+
/* Then */
511+
XCTAssertEqual(mockClient.executedRequests.count, 1, "Poll must skip a receipt already marked successful")
512+
}
513+
514+
/**
515+
A success marker only suppresses re-sends until its TTL elapses. Once expired and pruned, the same
516+
notificationId can be confirmed again on a later relaunch.
517+
*/
518+
func testReceiveReceiptResentAfterMarkerExpires() throws {
519+
/* Setup */
520+
let mockClient = setUpSubscribedUser()
521+
522+
let expiredNotif = "expired-notification-id"
523+
let request1 = OSRequestLiveActivityReceiveReceipts(key: expiredNotif, activityType: "my-activity-type", activityId: "my-activity-id")
524+
let request2 = OSRequestLiveActivityReceiveReceipts(key: expiredNotif, activityType: "my-activity-type", activityId: "my-activity-id")
525+
mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]())
526+
mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]())
527+
528+
// First launch: send the receipt, then age its persisted marker past the TTL.
529+
let firstLaunch = MockDispatchQueue()
530+
let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch)
531+
executor1.append(request1)
532+
firstLaunch.waitForDispatches(2)
533+
executor1.receiveReceipts.items[expiredNotif]?.timestamp = Date(timeIntervalSinceNow: -(ReceiveReceiptsRequestCache.OneDayInSeconds + 60))
534+
535+
// Any later save prunes the expired marker; append an unrelated receipt to trigger one.
536+
let other = OSRequestLiveActivityReceiveReceipts(key: "other-notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
537+
mockClient.setMockResponseForRequest(request: String(describing: other), response: [String: Any]())
538+
executor1.append(other)
539+
firstLaunch.waitForDispatches(4)
540+
XCTAssertNil(executor1.receiveReceipts.items[expiredNotif], "Expired marker should be pruned on the next save")
541+
542+
/* When */
543+
// Relaunch: the expired notificationId is re-emitted and must be sent again.
544+
let secondLaunch = MockDispatchQueue()
545+
let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch)
546+
executor2.append(request2)
547+
secondLaunch.waitForDispatches(2)
548+
549+
/* Then */
550+
XCTAssertTrue(mockClient.executedRequests.contains { $0 == request2 }, "Receipt should be re-sent after its marker expires")
551+
}
552+
553+
/**
554+
A non-retryable failure removes the receipt from the cache, leaving no marker, so a later re-emit of
555+
the same notificationId is sent again rather than suppressed.
556+
*/
557+
func testReceiveReceiptResentAfterNonRetryableFailure() throws {
558+
/* Setup */
559+
let mockClient = setUpSubscribedUser()
560+
561+
let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
562+
mockClient.setMockFailureResponseForRequest(request: String(describing: request1), error: OneSignalClientError(code: 401, message: "not-important", responseHeaders: nil, response: nil, underlyingError: nil))
563+
564+
/* When */
565+
let mockDispatchQueue = MockDispatchQueue()
566+
let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue)
567+
executor.append(request1)
568+
mockDispatchQueue.waitForDispatches(2)
569+
XCTAssertEqual(executor.receiveReceipts.items.count, 0, "A non-retryable failure should leave no dedup marker")
570+
571+
// Re-emit the same notificationId; this time it succeeds and must be sent again.
572+
let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id")
573+
mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]())
574+
executor.append(request2)
575+
mockDispatchQueue.waitForDispatches(4)
576+
577+
/* Then */
578+
XCTAssertEqual(mockClient.executedRequests.count, 2, "Receipt should be re-sent after a non-retryable failure")
579+
XCTAssertTrue(executor.receiveReceipts.items["my-notification-id"]?.requestSuccessful ?? false)
580+
}
581+
}

0 commit comments

Comments
 (0)