diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index ab6fc07bd..339e37715 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -401,6 +401,7 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; @@ -1849,6 +1850,7 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2578,6 +2580,7 @@ 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4613,6 +4616,7 @@ 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index fda6367cd..4d2874dcb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -49,7 +49,7 @@ final class EarlyTriggerTrackingTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 43f128826..25f2bc264 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -43,7 +43,7 @@ final class IAMIntegrationTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() // Temp. logging to help debug during testing OneSignalLog.setLogLevel(.LL_VERBOSE) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 1facda710..62ceeba99 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -50,7 +50,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift index 66751e3a0..3075e9cbb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift @@ -28,17 +28,31 @@ @objc public class OSIamFetchReadyCondition: NSObject, OSCondition { // the id used to index the token map (e.g. onesignalId) private let id: String + + private let stateLock = NSLock() private var hasSubscriptionUpdatePending: Bool = false - // Singleton shared instance initialized with default empty id - private static var instance: OSIamFetchReadyCondition? + private static let instancesLock = NSLock() + private static var instances: [String: OSIamFetchReadyCondition] = [:] - // Method to get or initialize the shared instance + /** + One condition per id, so a fetch waits on the same object the subscription listener armed, and a + fetch for a user who just switched in is not answered by the previous user's tokens. + */ @objc public static func sharedInstance(withId id: String) -> OSIamFetchReadyCondition { - if instance == nil { - instance = OSIamFetchReadyCondition(id: id) + return instancesLock.withLock { + if let existing = instances[id] { + return existing + } + let condition = OSIamFetchReadyCondition(id: id) + instances[id] = condition + return condition } - return instance! + } + + /// Test seam; the instances otherwise live as long as the process. + @objc public static func reset() { + instancesLock.withLock { instances = [:] } } // Private initializer to prevent external instantiation @@ -53,8 +67,16 @@ return OSIamFetchReadyCondition.CONDITIONID } + /// Raises the bar for the next fetch: an in-session subscription change is only readable once its + /// own token arrives, so waiting on the user token alone would fetch before the server can see it. public func setSubscriptionUpdatePending(value: Bool) { - hasSubscriptionUpdatePending = value + stateLock.withLock { hasSubscriptionUpdatePending = value } + } + + /// The fetch this was raised for has been released, so later fetches stop waiting on a subscription + /// token that has no update behind it. + @objc public func onConditionSatisfied() { + setSubscriptionUpdatePending(value: false) } public func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { @@ -71,7 +93,7 @@ return true } - if hasSubscriptionUpdatePending { + if stateLock.withLock({ hasSubscriptionUpdatePending }) { return userUpdateTokenSet && subscriptionTokenSet } return userUpdateTokenSet diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift index c63807819..ffef386ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift @@ -32,4 +32,8 @@ import Foundation var conditionId: String { get } func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? + + /// Called once a waiter on this condition has been released, so a condition that raised its own bar + /// for that wait can lower it again instead of holding every later waiter to it. + @objc optional func onConditionSatisfied() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index ac7129de8..50de9c001 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -32,17 +32,30 @@ import OneSignalCore // Singleton instance @objc public static let shared = OSConsistencyManager() - private let queue = DispatchQueue(label: "com.consistencyManager.queue") + // Serial, and the only place `indexedTokens` and `indexedConditions` may be touched. + // Non-private so test helpers can synchronize with it. + let queue = DispatchQueue(label: "com.consistencyManager.queue") private var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:] - private var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] // Index conditions by condition id + // Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. Non-private for tests. + var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] + + /** + How long a waiter blocks before proceeding with whatever token it has. A response that never arrives — + the device is offline, or the endpoint stopped returning `ryw_token` and has no call that resolves the + condition — would otherwise hold the calling thread for the life of the process. + Non-private so tests can shorten it. + */ + static var waitTimeout: DispatchTimeInterval = .seconds(30) // Private initializer to prevent multiple instances private override init() {} // Used for testing public func reset() { - indexedTokens = [:] - indexedConditions = [:] + queue.sync { + self.indexedTokens = [:] + self.indexedConditions = [:] + } } // Function to set the token in a thread-safe manner @@ -57,52 +70,67 @@ import OneSignalCore } } - // Register a condition and block the caller until the condition is met + /// Blocks the caller until the condition is met or `waitTimeout` elapses, then returns the newest + /// token the condition accepts, which is nil when it was released without one. @objc public func getRywTokenFromAwaitableCondition(_ condition: OSCondition, forId id: String) -> OSReadYourWriteData? { let semaphore = DispatchSemaphore(value: 0) queue.sync { - if self.indexedConditions[id] == nil { - self.indexedConditions[id] = [] - } - self.indexedConditions[id]?.append((condition, semaphore)) + self.indexedConditions[id, default: []].append((condition, semaphore)) self.checkConditionsAndComplete(forId: id) } - semaphore.wait() // Block until the condition is met + if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") + queue.sync { + // Skip if a met-path release already removed this waiter. + guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else { + return + } + // Clear so later fetches for this id are not held to a subscription token that never arrives. + condition.onConditionSatisfied?() + self.indexedConditions[id]?.removeAll { $0.1 === semaphore } + } + } return queue.sync { return condition.getNewestToken(indexedTokens: self.indexedTokens) } } - // Method to resolve conditions by condition ID (e.g. OSIamFetchReadyCondition.ID) - @objc public func resolveConditionsWithID(id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { - if condition.conditionId == id { - semaphore.signal() - completedConditions.append((condition, semaphore)) + /** + Releases waiters on `conditionId` registered under `id` (e.g. onesignalId). Used when that user's + response carried no `ryw_token`, so those waiters have nothing left to wait for. + */ + @objc(resolveConditionsWithConditionId:forId:) + public func resolveConditions(conditionId: String, forId id: String) { + queue.sync { + guard let waiters = self.indexedConditions[id] else { + return } - } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) + for (condition, semaphore) in waiters where condition.conditionId == conditionId { + OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(id)") + self.release(condition, semaphore) + } + self.indexedConditions[id] = waiters.filter { $0.0.conditionId != conditionId } } } // Private method to check conditions for a specific id (unique ID like onesignalId) private func checkConditionsAndComplete(forId id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { + guard let waiters = indexedConditions[id] else { return } + var stillWaiting: [(OSCondition, DispatchSemaphore)] = [] + for (condition, semaphore) in waiters { if condition.isMet(indexedTokens: indexedTokens) { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition met for id: \(id)") - semaphore.signal() - completedConditions.append((condition, semaphore)) + release(condition, semaphore) } else { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition not met for id: \(id)") + stillWaiting.append((condition, semaphore)) } } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) - } + indexedConditions[id] = stillWaiting + } + + private func release(_ condition: OSCondition, _ semaphore: DispatchSemaphore) { + condition.onConditionSatisfied?() + semaphore.signal() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift index 53d105943..8649a1a07 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift @@ -28,6 +28,13 @@ import OneSignalOSCore public class ConsistencyManagerTestHelpers { + /// Clears both halves of the read-your-write state: the manager's tokens and waiters, and the + /// per-id conditions, which otherwise carry a raised subscription bar into the next test. + public static func reset() { + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + /// Unblocks the Consistency Manager, which allows fetching of IAMs for example. public static func setDefaultRywToken(id: String) { let key = OSIamFetchOffsetKey.userUpdate diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index b48125850..e422a0e65 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -8,19 +8,24 @@ import Foundation import XCTest -import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +@testable import OneSignalOSCore class OSConsistencyManagerTests: XCTestCase { var consistencyManager: OSConsistencyManager! + private var defaultWaitTimeout: DispatchTimeInterval! override func setUp() { super.setUp() // Use the shared instance of OSConsistencyManager consistencyManager = OSConsistencyManager.shared + defaultWaitTimeout = OSConsistencyManager.waitTimeout } override func tearDown() { - consistencyManager.reset() + OSConsistencyManager.waitTimeout = defaultWaitTimeout + ConsistencyManagerTestHelpers.reset() super.tearDown() } @@ -92,35 +97,28 @@ class OSConsistencyManagerTests: XCTestCase { // Test: registerCondition does not complete when condition is not met func testRegisterConditionDoesNotCompleteWhenConditionIsNotMet() { - // Given a condition that will never be met - let condition = TestUnmetCondition() + OSConsistencyManager.waitTimeout = .milliseconds(500) let id = "test_id" - let rywDelay = 500 as NSNumber - - // Start on a background queue to simulate async behavior - DispatchQueue.global().async { - // Register the condition asynchronously - let rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? - // Since the condition will never be met, rywToken should remain nil - XCTAssertNil(rywData) + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: id) + returned.fulfill() + } + OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - // Set an unrelated token to verify that the unmet condition still doesn't complete - self.consistencyManager.setRywTokenAndDelay( + // A token for another id must not release this waiter + consistencyManager.setRywTokenAndDelay( id: "unrelated_id", key: OSIamFetchOffsetKey.userUpdate, - value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: rywDelay) - ) - - // newest token should still be nil as the condition is not met - XCTAssertNil(rywData) - } + value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: 500) + ) + XCTAssertEqual(consistencyManager.waiterCount, 1) - // Use a short delay to let the async behavior complete without waiting indefinitely - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { - XCTAssertTrue(true) // Simulate some async action completing without hanging - } - } + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + } func testSetRywTokenWithoutAnyCondition() { // Given @@ -282,6 +280,87 @@ class OSConsistencyManagerTests: XCTestCase { XCTAssertEqual(rywData?.rywToken, "456") } + + // MARK: - Releasing waiters + + /// A response with no `ryw_token` releases waiters for that user only. + func testResolvingByConditionIdReleasesWaitersForThatId() { + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: "onesignal-id") + + wait(for: [returned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 0) + } + + /// Resolving user B leaves user A's waiter registered. + func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let userA = "onesignal-id-a" + let userB = "onesignal-id-b" + + let aReturned = expectation(description: "user A waiter returned") + let bReturned = expectation(description: "user B waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userA) + aReturned.fulfill() + } + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userB) + bReturned.fulfill() + } + OneSignalCoreMocks.waitUntil("both waiters registered") { self.consistencyManager.waiterCount == 2 } + + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: userB) + + wait(for: [bReturned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") + // Drain A's timeout so the thread is not left blocked after the test. + wait(for: [aReturned], timeout: 2.0) + } + + func testResolvingADifferentConditionLeavesTheWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + OneSignalCoreMocks.waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") + + XCTAssertEqual(consistencyManager.waiterCount, 1) + // Drain the timeout so the thread is not left blocked after the test. + wait(for: [returned], timeout: 2.0) + } + + /// A condition nothing ever meets must not hold its thread for the life of the process. + func testAWaiterGivesUpWhenItsConditionIsNeverMet() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + XCTAssertEqual(consistencyManager.waiterCount, 0, "a waiter that gave up has to deregister") + } +} + +extension OSConsistencyManager { + /// Waiters registered right now. Tests poll it while the manager mutates on its own queue. + var waiterCount: Int { + return queue.sync { indexedConditions.values.reduce(0) { $0 + $1.count } } + } } // Mock implementation of OSCondition that simulates a condition that isn't met diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift new file mode 100644 index 000000000..f1c122a23 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -0,0 +1,208 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import XCTest +import OneSignalCoreMocks +import OneSignalOSCoreMocks +@testable import OneSignalOSCore + +/// Covers what an IAM fetch waits for, and for whom. +final class OSIamFetchReadyConditionTests: XCTestCase { + + private let userA = "onesignal-id-a" + private let userB = "onesignal-id-b" + private var defaultWaitTimeout: DispatchTimeInterval! + + override func setUp() { + super.setUp() + defaultWaitTimeout = OSConsistencyManager.waitTimeout + ConsistencyManagerTestHelpers.reset() + } + + override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout + ConsistencyManagerTestHelpers.reset() + super.tearDown() + } + + // MARK: - One condition per id + + func testTheSameIdGetsTheSameCondition() { + let first = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let second = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(first === second, "the fetch has to wait on the object the listener armed") + } + + /// A user switch must not be answered by the previous user's condition, which reads their tokens. + func testADifferentIdGetsItsOwnCondition() { + let forUserA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let forUserB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + + XCTAssertFalse(forUserA === forUserB) + XCTAssertTrue(forUserA.isMet(indexedTokens: [userA: userCreateToken()])) + XCTAssertFalse(forUserB.isMet(indexedTokens: [userA: userCreateToken()]), + "the new user's fetch must not be released by the previous user's token") + } + + // MARK: - What the condition waits for + + func testAUserUpdateTokenIsEnoughWithNoSubscriptionUpdatePending() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + func testAPendingSubscriptionUpdateAlsoWaitsForItsToken() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + XCTAssertFalse(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken().merging(subscriptionToken()) { current, _ in current }])) + } + + /// The raised bar belongs to the fetch it was raised for; leaving it up makes every later fetch + /// wait on a subscription token that has no update behind it. + func testTheSubscriptionBarComesBackDownOnceTheFetchIsReleased() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + condition.onConditionSatisfied() + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + // MARK: - Through the Consistency Manager + + func testTheManagerLowersTheBarWhenItReleasesTheWaiter() { + let manager = OSConsistencyManager.shared + OSIamFetchReadyCondition.sharedInstance(withId: userA).setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + firstReturned.fulfill() + } + OneSignalCoreMocks.waitUntil("first fetch waiting") { manager.waiterCount == 1 } + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + XCTAssertEqual(manager.waiterCount, 1, "a pending subscription update still owes a token") + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.subscriptionUpdate, value: token("200")) + wait(for: [firstReturned], timeout: 2.0) + + // The next fetch has no subscription update behind it, so the user token alone releases it. + let secondReturned = expectation(description: "second fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + + /// The `ryw_token`-missing fallback the executors call for that user. + func testResolvingTheConditionReleasesTheFetch() { + let manager = OSConsistencyManager.shared + let returned = expectation(description: "fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + returned.fulfill() + } + OneSignalCoreMocks.waitUntil("fetch waiting") { manager.waiterCount == 1 } + + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userA) + + wait(for: [returned], timeout: 2.0) + } + + /// Resolving user B leaves user A's subscription bar raised. + func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { + let manager = OSConsistencyManager.shared + let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let conditionB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + conditionA.setSubscriptionUpdatePending(value: true) + conditionB.setSubscriptionUpdatePending(value: true) + + let bReturned = expectation(description: "user B fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(conditionB, forId: self.userB) + bReturned.fulfill() + } + OneSignalCoreMocks.waitUntil("user B waiting") { manager.waiterCount == 1 } + + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userB) + wait(for: [bReturned], timeout: 2.0) + + XCTAssertFalse(conditionA.isMet(indexedTokens: [userA: userUpdateToken()]), + "user A's subscription bar must still be up") + XCTAssertTrue(conditionB.isMet(indexedTokens: [userB: userUpdateToken()]), + "user B's bar comes down with its own resolve") + } + + /// After a timeout, a later fetch for the same id is released by the user token alone. + func testTimingOutLowersTheSubscriptionBar() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch timed out") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + firstReturned.fulfill() + } + wait(for: [firstReturned], timeout: 2.0) + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + + let secondReturned = expectation(description: "second fetch released by user token alone") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + + // MARK: - Helpers + + private func token(_ value: String) -> OSReadYourWriteData { + return OSReadYourWriteData(rywToken: value, rywDelay: 0) + } + + private func userCreateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userCreate.rawValue): token("create")] + } + + private func userUpdateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue): token("update")] + } + + private func subscriptionToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue): token("subscription")] + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index e8519205d..fe665da6d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -28,6 +28,7 @@ import Foundation import XCTest import OneSignalCore +import OneSignalOSCoreMocks @testable import OneSignalOSCore /// Covers `flushDeltaQueue` routing: matched deltas go to executors and leave the repo @@ -40,22 +41,19 @@ final class OSOperationRepoFlushTests: XCTestCase { override func setUp() { super.setUp() OneSignalIdentifiers.currentAppId = "test-app-id" - resetOperationRepo() + OSCoreMocks.resetOperationRepo() // Pause so the poller (started by addExecutor/start) cannot flush mid-setup. OSOperationRepo.sharedInstance.paused = true OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 60_000 } override func tearDown() { - resetOperationRepo() + OSCoreMocks.resetOperationRepo() super.tearDown() } func testFlush_sendsMatchedDeltasToExecutorAndClearsRepoQueue() { let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) - let processExpectation = expectation(description: "processDeltaQueue") - executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) @@ -63,11 +61,9 @@ final class OSOperationRepoFlushTests: XCTestCase { let deltaB = makeDelta(name: knownDelta, property: "b") repo.enqueueDelta(deltaA) repo.enqueueDelta(deltaB) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } repo.paused = false - repo.addFlushDeltaQueueToDispatchQueue() - wait(for: [processExpectation], timeout: 2.0) + repo.flushAndWait() XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"]) XCTAssertTrue(repo.deltaQueue.isEmpty) @@ -75,9 +71,6 @@ final class OSOperationRepoFlushTests: XCTestCase { func testFlush_keepsUnmatchedDeltasInRepoQueue() { let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) - let processExpectation = expectation(description: "processDeltaQueue") - executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) @@ -85,11 +78,9 @@ final class OSOperationRepoFlushTests: XCTestCase { let deltaB = makeDelta(name: unknownDelta, property: "b") repo.enqueueDelta(deltaA) repo.enqueueDelta(deltaB) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } repo.paused = false - repo.addFlushDeltaQueueToDispatchQueue() - wait(for: [processExpectation], timeout: 2.0) + repo.flushAndWait() XCTAssertTrue(executor.enqueued.isEmpty) XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"]) @@ -97,9 +88,6 @@ final class OSOperationRepoFlushTests: XCTestCase { func testFlush_routesMatchedAndPreservesUnmatchedOrder() { let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) - let processExpectation = expectation(description: "processDeltaQueue") - executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) @@ -109,11 +97,9 @@ final class OSOperationRepoFlushTests: XCTestCase { repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-2")) repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-2")) repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-3")) - waitUntil("all deltas enqueued") { repo.deltaQueue.count == 5 } repo.paused = false - repo.addFlushDeltaQueueToDispatchQueue() - wait(for: [processExpectation], timeout: 2.0) + repo.flushAndWait() XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"]) XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"]) @@ -121,14 +107,6 @@ final class OSOperationRepoFlushTests: XCTestCase { // MARK: - Helpers - private func resetOperationRepo() { - let repo = OSOperationRepo.sharedInstance - repo.deltaQueue.removeAll() - repo.executors.removeAll() - repo.deltasToExecutorMap.removeAll() - repo.paused = false - } - private func makeDelta(name: String, property: String) -> OSDelta { OSDelta( name: name, @@ -138,31 +116,11 @@ final class OSOperationRepoFlushTests: XCTestCase { value: property ) } - - private func waitUntil( - _ description: String, - timeout: TimeInterval = 2.0, - file: StaticString = #filePath, - line: UInt = #line, - _ condition: @escaping () -> Bool - ) { - let exp = expectation(description: description) - let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in - if condition() { - timer.invalidate() - exp.fulfill() - } - } - let result = XCTWaiter.wait(for: [exp], timeout: timeout) - timer.invalidate() - XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) - } } private final class MockOperationExecutor: OSOperationExecutor { let supportedDeltas: [String] private(set) var enqueued: [OSDelta] = [] - var onProcessDeltaQueue: (() -> Void)? init(supportedDeltas: [String]) { self.supportedDeltas = supportedDeltas @@ -174,7 +132,5 @@ private final class MockOperationExecutor: OSOperationExecutor { func cacheDeltaQueue() {} - func processDeltaQueue(inBackground: Bool) { - onProcessDeltaQueue?() - } + func processDeltaQueue(inBackground: Bool) {} } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 88f29af73..0df165df2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -276,7 +276,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } onFailure: { error in diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f100bbfd9..f8c985cce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -311,7 +311,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } @@ -431,7 +431,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 1b8d0e5b3..477f11540 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -271,7 +271,7 @@ extension OSUserExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } }