diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 1a57c17ae..6989f55cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */; }; 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; }; 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; }; + 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; }; 3C4319092F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */; }; 3C44673E296D099D0039A49E /* OneSignalMobileProvision.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411FD1E73342200E41FD7 /* OneSignalMobileProvision.m */; }; 3C44673F296D09CC0039A49E /* OneSignalMobileProvision.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411FC1E73342200E41FD7 /* OneSignalMobileProvision.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1351,6 +1352,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = ""; }; 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = ""; }; 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = ""; }; + 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = ""; }; 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEndOutcomesRequestTests.swift; sourceTree = ""; }; 3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSBackgroundTaskHandlerImpl.h; sourceTree = ""; }; 3C448B9C2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSBackgroundTaskHandlerImpl.m; sourceTree = ""; }; @@ -2525,6 +2527,7 @@ isa = PBXGroup; children = ( 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, + 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, @@ -4546,6 +4549,7 @@ 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */, 3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */, 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, + 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 95dc07ec8..223080697 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -151,16 +151,16 @@ public class OSOperationRepo: NSObject { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo flushDeltaQueue in background: \(inBackground) with queue: \(self.deltaQueue)") } - var index = 0 + var unmatched: [OSDelta] = [] for delta in self.deltaQueue { if let executor = self.deltasToExecutorMap[delta.name] { executor.enqueueDelta(delta) - self.deltaQueue.remove(at: index) } else { - // keep in queue if no executor matches, we may not have the executor available yet - index += 1 + // Keep if no executor matches yet (module may not have started). + unmatched.append(delta) } } + self.deltaQueue = unmatched // Persist the deltas (including removed deltas) to storage after they are divvy'd up to executors. OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift new file mode 100644 index 000000000..e8519205d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -0,0 +1,180 @@ +/* + 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 OneSignalCore +@testable import OneSignalOSCore + +/// Covers `flushDeltaQueue` routing: matched deltas go to executors and leave the repo +/// queue; unmatched names stay queued (and in order). +final class OSOperationRepoFlushTests: XCTestCase { + + private let knownDelta = "test_known_delta" + private let unknownDelta = "test_unknown_delta" + + override func setUp() { + super.setUp() + OneSignalIdentifiers.currentAppId = "test-app-id" + 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() + 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) + + let deltaA = makeDelta(name: knownDelta, property: "a") + 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) + + XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"]) + XCTAssertTrue(repo.deltaQueue.isEmpty) + } + + func testFlush_keepsUnmatchedDeltasInRepoQueue() { + let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) + let processExpectation = expectation(description: "processDeltaQueue") + executor.onProcessDeltaQueue = { processExpectation.fulfill() } + + let repo = OSOperationRepo.sharedInstance + repo.addExecutor(executor) + + let deltaA = makeDelta(name: unknownDelta, property: "a") + 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) + + XCTAssertTrue(executor.enqueued.isEmpty) + XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"]) + } + + func testFlush_routesMatchedAndPreservesUnmatchedOrder() { + let executor = MockOperationExecutor(supportedDeltas: [knownDelta]) + let processExpectation = expectation(description: "processDeltaQueue") + executor.onProcessDeltaQueue = { processExpectation.fulfill() } + + let repo = OSOperationRepo.sharedInstance + repo.addExecutor(executor) + + // Interleaved matched/unmatched: assert dispatch order and retained queue order. + repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-1")) + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-1")) + 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) + + XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"]) + XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"]) + } + + // 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, + identityModelId: UUID().uuidString, + model: OSModel(changeNotifier: OSEventProducer()), + property: property, + 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 + } + + func enqueueDelta(_ delta: OSDelta) { + enqueued.append(delta) + } + + func cacheDeltaQueue() {} + + func processDeltaQueue(inBackground: Bool) { + onProcessDeltaQueue?() + } +}