@@ -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