diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift index 23c7b3082..1afa990e7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Executors/OSLiveActivitiesExecutor.swift @@ -115,11 +115,12 @@ class StartRequestCache: RequestCache { } class ReceiveReceiptsRequestCache: RequestCache { - // Keep receive receipts requests for up to 30 days. - static let OneMonthInSeconds = TimeInterval(60 * 60 * 24 * 30) + // Sent receipts are kept as dedup markers, so bound retention: an active activity re-emits for at most + // ~12h on relaunch, and updates arrive over the network, so a day covers dedup and retries without piling up. + static let OneDayInSeconds = TimeInterval(60 * 60 * 24) init() { - super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.OneMonthInSeconds) + super.init(cacheKey: OS_LIVE_ACTIVITIES_EXECUTOR_RECEIVE_RECEIPTS_KEY, ttl: ReceiveReceiptsRequestCache.OneDayInSeconds) } } @@ -141,10 +142,11 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { // The live activities request dispatch queue, serial. This synchronizes access to `updateTokens` and `startTokens`. private var requestDispatch: OSDispatchQueue - private var pollIntervalSeconds = 30 + private var pollIntervalSeconds: Int - init(requestDispatch: OSDispatchQueue) { + init(requestDispatch: OSDispatchQueue, pollIntervalSeconds: Int = 30) { self.requestDispatch = requestDispatch + self.pollIntervalSeconds = pollIntervalSeconds } func start() { @@ -184,7 +186,7 @@ class OSLiveActivitiesExecutor: OSPushSubscriptionObserver { } } - private func pollPendingRequests() { + func pollPendingRequests() { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.LiveActivities pollPendingRequests") self.requestDispatch.asyncAfterTime(deadline: .now() + .seconds(pollIntervalSeconds)) { [weak self] in diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift index 64ee3d0d1..cdcf6fc8c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OneSignalLiveActivitiesManagerImpl.swift @@ -334,7 +334,7 @@ public class OneSignalLiveActivitiesManagerImpl: NSObject, OSLiveActivities { Task { for await content in activity.contentUpdates { // Don't track a live activity started / updated "in app" without a notification - if let notificationId = activity.content.state.onesignal?.notificationId { + if let notificationId = content.state.onesignal?.notificationId { OneSignalLiveActivitiesManagerImpl.addReceiveReceipts(notificationId: notificationId, activityType: "\(activityType)", activityId: activity.attributes.onesignal.activityId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift index f5ef0561b..f1058b995 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/Requests/OSRequestLiveActivityReceiveReceipts.swift @@ -36,7 +36,8 @@ class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequ var activityType: String var activityId: String var requestSuccessful: Bool - var shouldForgetWhenSuccessful: Bool = true + // Kept after success so the persisted cache suppresses re-sends across relaunches. + var shouldForgetWhenSuccessful: Bool = false func prepareForExecution() -> Bool { guard let appId = OneSignalIdentifiers.currentAppId else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index 6feb7c122..dc02f994c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -49,9 +49,8 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { override func tearDownWithError() throws { } - func testAppendSetStartTokenWithSuccessfulRequest() throws { - /* Setup */ - let mockDispatchQueue = MockDispatchQueue() + // Subscribes a user, then resets the client so tests assert only on the requests they make. + private func setUpSubscribedUser() -> MockOneSignalClient { let mockClient = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(mockClient) OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") @@ -59,6 +58,13 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { // Wait for any user setup requests to complete OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) mockClient.reset() + return mockClient + } + + func testAppendSetStartTokenWithSuccessfulRequest() throws { + /* Setup */ + let mockDispatchQueue = MockDispatchQueue() + let mockClient = setUpSubscribedUser() let request = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -79,13 +85,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRemoveStartTokenWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestRemoveStartToken(key: "my-activity-type") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -104,13 +104,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateTokenWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -131,13 +125,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRemoveUpdateTokenWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestRemoveUpdateToken(key: "my-activity-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -156,13 +144,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testReceiveReceiptsWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestLiveActivityReceiveReceipts(key: "notification-id", activityType: "my-activity-type", activityId: "my-activity-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -173,7 +155,9 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { mockDispatchQueue.waitForDispatches(2) /* Then */ - XCTAssertEqual(executor.receiveReceipts.items.count, 0) + // The sent receipt is kept as a dedup marker so the same notificationId isn't reported again. + XCTAssertEqual(executor.receiveReceipts.items.count, 1) + XCTAssertTrue(executor.receiveReceipts.items["notification-id"]?.requestSuccessful ?? false) XCTAssertEqual(mockClient.executedRequests.count, 1) XCTAssertTrue(mockClient.executedRequests[0] == request) } @@ -181,13 +165,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testClickEventWithSuccessfulRequest() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestLiveActivityClicked(key: "unique-click-id", activityType: "my-activity-type", activityId: "my-activity-id", notificationId: "my-notif-id") mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) @@ -225,13 +203,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRequestStaysInCacheForRetryableError() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") mockClient.setMockFailureResponseForRequest(request: String(describing: request), error: OneSignalClientError(code: 500, message: "not-important", responseHeaders: nil, response: nil, underlyingError: nil)) @@ -252,13 +224,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testRequestRemovedFromCacheForNonRetryableError() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") mockClient.setMockFailureResponseForRequest(request: String(describing: request), error: OneSignalClientError(code: 401, message: "not-important", responseHeaders: nil, response: nil, underlyingError: nil)) @@ -319,13 +285,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetStartRequestNotExecutedWithSameActivityTypeAndToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") let request2 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token") @@ -348,13 +308,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetStartRequestNotExecutedWithSameActivityTypeAndDiffToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token-1") let request2 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token-2") @@ -378,13 +332,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetStartRequestFollowedByRemoveStartIsSuccessful() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetStartToken(key: "my-activity-type", token: "my-token-1") let request2 = OSRequestRemoveStartToken(key: "my-activity-type") @@ -407,13 +355,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateRequestNotExecutedWithSameActivityIdAndToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") let request2 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token") @@ -436,13 +378,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateRequestNotExecutedWithSameActivityIdAndDiffToken() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token-1") let request2 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token-2") @@ -466,13 +402,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testSetUpdateRequestFollowedByRemoveUpdateIsSuccessful() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestSetUpdateToken(key: "my-activity-id", token: "my-token-1") let request2 = OSRequestRemoveUpdateToken(key: "my-activity-id") @@ -495,13 +425,7 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { func testReceiveReceiptsRequestNotExecutedWithSameNotificationId() throws { /* Setup */ let mockDispatchQueue = MockDispatchQueue() - let mockClient = MockOneSignalClient() - OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + let mockClient = setUpSubscribedUser() let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type-1", activityId: "my-activity-id-1") let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type-2", activityId: "my-activity-id-2") @@ -515,8 +439,143 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { mockDispatchQueue.waitForDispatches(3) /* Then */ - XCTAssertEqual(executor.receiveReceipts.items.count, 0) + // request2 is suppressed as a duplicate of request1, which is kept as a dedup marker. + XCTAssertEqual(executor.receiveReceipts.items.count, 1) + XCTAssertEqual(mockClient.executedRequests.count, 1) + XCTAssertTrue(mockClient.executedRequests[0] == request1) + } + + /** + A receive receipt must be sent at most once per device, even across app launches. The second executor + reloads the persisted cache with in-memory state gone, simulating a relaunch where ActivityKit re-emits + the active activity's current content and the same notificationId is reported again. + */ + func testReceiveReceiptsNotResentForSameNotificationIdAfterRelaunch() throws { + /* Setup */ + let mockClient = setUpSubscribedUser() + + let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]()) + mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]()) + + /* When */ + // First launch: the receipt is sent and succeeds. + let firstLaunch = MockDispatchQueue() + let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch) + executor1.append(request1) + firstLaunch.waitForDispatches(2) XCTAssertEqual(mockClient.executedRequests.count, 1) + + // Relaunch: a fresh executor reloads the persisted cache; ActivityKit re-emits the same content. + let secondLaunch = MockDispatchQueue() + let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch) + executor2.append(request2) + secondLaunch.waitForDispatches(1) + + /* Then */ + XCTAssertEqual(mockClient.executedRequests.count, 1, "Receive receipt must not be re-sent for the same notificationId after relaunch") XCTAssertTrue(mockClient.executedRequests[0] == request1) } } + +// Receive-receipt dedup robustness: retry poll, TTL expiry, and non-retryable failure. +extension OSLiveActivitiesExecutorTests { + /** + After relaunch the persisted success marker is reloaded; the retry poll only re-executes unsent + requests, so an already-confirmed notificationId must not be sent again. + */ + func testPollDoesNotResendReloadedSuccessfulReceipt() throws { + /* Setup */ + let mockClient = setUpSubscribedUser() + + let request = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: request), response: [String: Any]()) + + // First launch: send the receipt so a success marker is persisted. + let firstLaunch = MockDispatchQueue() + let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch) + executor1.append(request) + firstLaunch.waitForDispatches(2) + XCTAssertEqual(mockClient.executedRequests.count, 1) + + /* When */ + // Relaunch and drive the retry poll directly against the reloaded marker. + let secondLaunch = MockDispatchQueue() + let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch, pollIntervalSeconds: 0) + XCTAssertTrue(executor2.receiveReceipts.items["my-notification-id"]?.requestSuccessful ?? false, "Success marker must survive reload") + executor2.pollPendingRequests() + secondLaunch.waitForDispatches(1) + + /* Then */ + XCTAssertEqual(mockClient.executedRequests.count, 1, "Poll must skip a receipt already marked successful") + } + + /** + A success marker only suppresses re-sends until its TTL elapses. Once expired and pruned, the same + notificationId can be confirmed again on a later relaunch. + */ + func testReceiveReceiptResentAfterMarkerExpires() throws { + /* Setup */ + let mockClient = setUpSubscribedUser() + + let expiredNotif = "expired-notification-id" + let request1 = OSRequestLiveActivityReceiveReceipts(key: expiredNotif, activityType: "my-activity-type", activityId: "my-activity-id") + let request2 = OSRequestLiveActivityReceiveReceipts(key: expiredNotif, activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: request1), response: [String: Any]()) + mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]()) + + // First launch: send the receipt, then age its persisted marker past the TTL. + let firstLaunch = MockDispatchQueue() + let executor1 = OSLiveActivitiesExecutor(requestDispatch: firstLaunch) + executor1.append(request1) + firstLaunch.waitForDispatches(2) + executor1.receiveReceipts.items[expiredNotif]?.timestamp = Date(timeIntervalSinceNow: -(ReceiveReceiptsRequestCache.OneDayInSeconds + 60)) + + // Any later save prunes the expired marker; append an unrelated receipt to trigger one. + let other = OSRequestLiveActivityReceiveReceipts(key: "other-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: other), response: [String: Any]()) + executor1.append(other) + firstLaunch.waitForDispatches(4) + XCTAssertNil(executor1.receiveReceipts.items[expiredNotif], "Expired marker should be pruned on the next save") + + /* When */ + // Relaunch: the expired notificationId is re-emitted and must be sent again. + let secondLaunch = MockDispatchQueue() + let executor2 = OSLiveActivitiesExecutor(requestDispatch: secondLaunch) + executor2.append(request2) + secondLaunch.waitForDispatches(2) + + /* Then */ + XCTAssertTrue(mockClient.executedRequests.contains { $0 == request2 }, "Receipt should be re-sent after its marker expires") + } + + /** + A non-retryable failure removes the receipt from the cache, leaving no marker, so a later re-emit of + the same notificationId is sent again rather than suppressed. + */ + func testReceiveReceiptResentAfterNonRetryableFailure() throws { + /* Setup */ + let mockClient = setUpSubscribedUser() + + let request1 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockFailureResponseForRequest(request: String(describing: request1), error: OneSignalClientError(code: 401, message: "not-important", responseHeaders: nil, response: nil, underlyingError: nil)) + + /* When */ + let mockDispatchQueue = MockDispatchQueue() + let executor = OSLiveActivitiesExecutor(requestDispatch: mockDispatchQueue) + executor.append(request1) + mockDispatchQueue.waitForDispatches(2) + XCTAssertEqual(executor.receiveReceipts.items.count, 0, "A non-retryable failure should leave no dedup marker") + + // Re-emit the same notificationId; this time it succeeds and must be sent again. + let request2 = OSRequestLiveActivityReceiveReceipts(key: "my-notification-id", activityType: "my-activity-type", activityId: "my-activity-id") + mockClient.setMockResponseForRequest(request: String(describing: request2), response: [String: Any]()) + executor.append(request2) + mockDispatchQueue.waitForDispatches(4) + + /* Then */ + XCTAssertEqual(mockClient.executedRequests.count, 2, "Receipt should be re-sent after a non-retryable failure") + XCTAssertTrue(executor.receiveReceipts.items["my-notification-id"]?.requestSuccessful ?? false) + } +}