diff --git a/.gitignore b/.gitignore index 6174637eb..b3367d14e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ profile DerivedData .idea/ iOS_SDK/Carthage/Build -/temp/ \ No newline at end of file +/temp/ +.build/ \ No newline at end of file diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 48f489b10..153ade0e8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -150,6 +150,7 @@ 3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; }; 3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; }; + 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */; }; 3CBB6C262ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */; }; 3CC063942B6D6B6B002BB07F /* OneSignalCore.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */; }; 3CC063A22B6D7A8E002BB07F /* OneSignalCoreMocks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */; }; @@ -1354,6 +1355,7 @@ 3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = ""; }; 3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = ""; }; 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = ""; }; + 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMessagingControllerUserStateTests.swift; sourceTree = ""; }; 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsistencyManagerTestHelpers.swift; sourceTree = ""; }; 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCore.m; sourceTree = ""; }; 3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalCoreMocks.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2158,6 +2160,7 @@ 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */, 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */, 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */, + 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, ); path = OneSignalInAppMessagesTests; @@ -4301,6 +4304,7 @@ 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */, 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, + 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index 82f951094..36ef44424 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -202,9 +202,15 @@ extension MockOneSignalClient { return found } - public func hasExecutedRequestOfType(_ type: AnyClass) -> Bool { - executedRequests.contains { request in + public func hasExecutedRequestOfType(_ type: AnyClass, expectedCount: Int? = nil) -> Bool { + let matchingCount = executedRequests.filter { request in request.isKind(of: type) + }.count + + if let expectedCount = expectedCount { + return matchingCount == expectedCount + } else { + return matchingCount > 0 } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h index aa1709e65..9693149e1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.h @@ -39,7 +39,7 @@ NS_ASSUME_NONNULL_BEGIN @end -@interface OSMessagingController : NSObject +@interface OSMessagingController : NSObject @property (class, readonly) BOOL isInAppMessagingPaused; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index c5b4ef521..d90f35d0a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -146,6 +146,9 @@ @interface OSMessagingController () @property (nonatomic) BOOL calledLoadTags; +/// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet +@property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID; + @end @implementation OSMessagingController @@ -175,6 +178,7 @@ + (void)removeInstance { + (void)start { OSMessagingController *shared = OSMessagingController.sharedInstance; [OneSignalUserManagerImpl.sharedInstance.pushSubscriptionImpl addObserver:shared]; + [OneSignalUserManagerImpl.sharedInstance addObserver:shared]; } static BOOL _isInAppMessagingPaused = false; @@ -254,8 +258,10 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { OSConsistencyManager *consistencyManager = [OSConsistencyManager shared]; NSString *onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId; + // NOTE: Check for subscription ID above first, before checking for OneSignal ID next if (!onesignalId) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID"]; + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID, will reattempt"]; + self.shouldFetchOnUserChangeWithSubscriptionID = subscriptionId; return; } @@ -1198,6 +1204,15 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _ [self getInAppMessagesFromServer:state.current.id]; } +- (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { + if (state.current.onesignalId != nil && self.shouldFetchOnUserChangeWithSubscriptionID) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onUserStateDidChangeWithState: changed to new valid onesignal id"]; + NSString *subscriptionID = self.shouldFetchOnUserChangeWithSubscriptionID; + self.shouldFetchOnUserChangeWithSubscriptionID = nil; + [self getInAppMessagesFromServer:subscriptionID]; + } +} + - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/.swiftlint.yml b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/.swiftlint.yml new file mode 100644 index 000000000..c6778fb1d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/.swiftlint.yml @@ -0,0 +1,3 @@ +# in tests, we may want to force cast and throw any errors +disabled_rules: + - force_cast diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift new file mode 100644 index 000000000..bfc661d7c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -0,0 +1,192 @@ +/* + Modified MIT License + + Copyright 2025 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 XCTest +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + Tests for OSMessagingController's user state observer functionality. + + These tests verify that IAM fetching is retried when the OneSignal ID becomes available + after an initial fetch attempt fails due to missing OneSignal ID during early app startup. + + Related to PR: https://github.com/OneSignal/OneSignal-iOS-SDK/pull/1626 + */ +final class OSMessagingControllerUserStateTests: XCTestCase { + + private let testSubscriptionId = "test-subscription-id-12345" + private let testOneSignalId = "test-onesignal-id-12345" + private let testExternalId = "test-external-id-12345" + private let testAppId = "test-app-id" + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSConsistencyManager.shared.reset() + OSMessagingController.removeInstance() + + // Set up basic configuration + OneSignalConfigManager.setAppId(testAppId) + OneSignalLog.setLogLevel(.LL_VERBOSE) + } + + override func tearDownWithError() throws { + OSMessagingController.removeInstance() + } + + /** + Test that when getInAppMessagesFromServer is called without a OneSignal ID, it stores the subscription ID for later retry. + + Scenario: + - User calls initialize() and login() early in app startup + - Push subscription ID is available but OneSignal ID is not yet set + - IAM fetch should be deferred + + Expected: + - shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID + - No IAM fetch actually occurs + */ + func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + + // Note: nothing has set OneSignal ID + + /* Execute */ + OneSignalInAppMessages.getFromServer(testSubscriptionId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Verify */ + // The controller should have stored the subscription ID for retry + let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") + XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId) + + // Verify no IAM request was actually made (since we don't have OneSignal ID) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) + } + + /** + Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch. + + Scenario: + - IAM fetch was previously deferred due to missing OneSignal ID + - User response is returned and OneSignal ID becomes available + - User state change observer is triggered + + Expected: + - IAM fetch is retried with the stored subscription ID + - shouldFetchOnUserChangeWithSubscriptionID is cleared + */ + func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Unblock consistency manager + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + + /* Execute */ + // Setup the anonymous user + MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId) + OneSignalUserManagerImpl.sharedInstance.start() + + // Login to a new user, without setting the client response, so onesignal ID is not hydrated + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + + // First attempt: Try to fetch IAMs without OneSignal ID (should be deferred) + OneSignalInAppMessages.getFromServer(testSubscriptionId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // Verify the subscription ID was stored and no IAM fetch occurred + XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) + + // Now let the login succeed, receive onesignal ID which fires user state observer + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.first?.sentToClient = false + OneSignalUserManagerImpl.sharedInstance.userExecutor?.executePendingRequests() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Verify */ + // The fetch should have been retried now that OneSignal ID is available + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + + // The stored subscription ID should be cleared after successful retry + XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + } + + /** + Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing. + + Scenario: + - Normal user state change occurs + - No deferred fetch was pending + + Expected: + - No retry logic is triggered + - Normal operation continues + */ + func testDoesNothingWhenNoRetryPending() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OneSignalInAppMessages.start() + let controller = OSMessagingController.sharedInstance() + + // Set up user with valid OneSignal ID from the start + MockUserRequests.setDefaultCreateAnonUserResponses( + with: client, + onesignalId: testOneSignalId, + subscriptionId: testSubscriptionId + ) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Verify */ + // IAM is fetched and no retry is pending + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + + /* Execute */ + // Trigger a normal user state change by login + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Verify */ + // Does not fetch IAMs again + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 09e41dc7c..c0f9be022 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -4,9 +4,12 @@ #import "OSInAppMessageInternal.h" #import "OSMessagingController.h" +#import "OSInAppMessagingRequests.h" // Expose private properties and methods for testing @interface OSMessagingController (Testing) @property (strong, nonatomic, nonnull) NSMutableArray *messageDisplayQueue; ++ (void)start; ++ (void)removeInstance; - (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message; @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift index 7864d2905..fd9225e92 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift @@ -76,8 +76,11 @@ extension MockUserRequests { } @objc - public static func setDefaultCreateAnonUserResponses(with client: MockOneSignalClient) { - let anonCreateResponse = testDefaultFullCreateUserResponse(onesignalId: anonUserOSID, externalId: nil, subscriptionId: testPushSubId) + public static func setDefaultCreateAnonUserResponses(with client: MockOneSignalClient, onesignalId: String? = nil, subscriptionId: String? = nil) { + let anonCreateResponse = testDefaultFullCreateUserResponse( + onesignalId: onesignalId ?? anonUserOSID, + externalId: nil, + subscriptionId: subscriptionId ?? testPushSubId) client.setMockResponseForRequest( request: "", diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m index 68c413f0b..fb26fd6b6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m @@ -37,7 +37,7 @@ - (void)testSendPurchases { [OneSignalUserManagerImpl.sharedInstance start]; // 1. Set up mock responses for the anonymous user - [MockUserRequests setDefaultCreateAnonUserResponsesWith:client]; + [MockUserRequests setDefaultCreateAnonUserResponsesWith:client onesignalId:nil subscriptionId:nil]; [OneSignalCoreImpl setSharedClient:client]; /* When */