Skip to content

Commit 8f87f3c

Browse files
committed
add tests for messaging controller and user state
* Add test class OSMessagingControllerUserStateTests * Update some existing test helpers
1 parent 5a97223 commit 8f87f3c

6 files changed

Lines changed: 212 additions & 5 deletions

File tree

iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; };
151151
3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
152152
3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */; };
153+
3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */; };
153154
3CBB6C262ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */; };
154155
3CC063942B6D6B6B002BB07F /* OneSignalCore.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063932B6D6B6B002BB07F /* OneSignalCore.m */; };
155156
3CC063A22B6D7A8E002BB07F /* OneSignalCoreMocks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */; };
@@ -1354,6 +1355,7 @@
13541355
3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = "<group>"; };
13551356
3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = "<group>"; };
13561357
3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTests.swift; sourceTree = "<group>"; };
1358+
3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMessagingControllerUserStateTests.swift; sourceTree = "<group>"; };
13571359
3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsistencyManagerTestHelpers.swift; sourceTree = "<group>"; };
13581360
3CC063932B6D6B6B002BB07F /* OneSignalCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCore.m; sourceTree = "<group>"; };
13591361
3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalCoreMocks.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -2158,6 +2160,7 @@
21582160
3C01519B2C2E29F90079E076 /* IAMRequestTests.m */,
21592161
3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */,
21602162
3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */,
2163+
3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */,
21612164
3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */,
21622165
);
21632166
path = OneSignalInAppMessagesTests;
@@ -4301,6 +4304,7 @@
43014304
3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */,
43024305
3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */,
43034306
3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */,
4307+
3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */,
43044308
);
43054309
runOnlyForDeploymentPostprocessing = 0;
43064310
};

iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,15 @@ extension MockOneSignalClient {
202202
return found
203203
}
204204

205-
public func hasExecutedRequestOfType(_ type: AnyClass) -> Bool {
206-
executedRequests.contains { request in
205+
public func hasExecutedRequestOfType(_ type: AnyClass, expectedCount: Int? = nil) -> Bool {
206+
let matchingCount = executedRequests.filter { request in
207207
request.isKind(of: type)
208+
}.count
209+
210+
if let expectedCount = expectedCount {
211+
return matchingCount == expectedCount
212+
} else {
213+
return matchingCount > 0
208214
}
209215
}
210216
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2025 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import XCTest
29+
import OneSignalOSCore
30+
import OneSignalCoreMocks
31+
import OneSignalOSCoreMocks
32+
import OneSignalUserMocks
33+
@testable import OneSignalUser
34+
35+
/**
36+
Tests for OSMessagingController's user state observer functionality.
37+
38+
These tests verify that IAM fetching is retried when the OneSignal ID becomes available
39+
after an initial fetch attempt fails due to missing OneSignal ID during early app startup.
40+
41+
Related to PR: https://github.com/OneSignal/OneSignal-iOS-SDK/pull/1626
42+
*/
43+
final class OSMessagingControllerUserStateTests: XCTestCase {
44+
45+
private let testSubscriptionId = "test-subscription-id-12345"
46+
private let testOneSignalId = "test-onesignal-id-12345"
47+
private let testExternalId = "test-external-id-12345"
48+
private let testAppId = "test-app-id"
49+
50+
override func setUpWithError() throws {
51+
OneSignalCoreMocks.clearUserDefaults()
52+
OneSignalUserMocks.reset()
53+
OSConsistencyManager.shared.reset()
54+
55+
// Set up basic configuration
56+
OneSignalConfigManager.setAppId(testAppId)
57+
OneSignalLog.setLogLevel(.LL_VERBOSE)
58+
}
59+
60+
override func tearDownWithError() throws {
61+
OSMessagingController.removeInstance()
62+
}
63+
64+
/**
65+
Test that when getInAppMessagesFromServer is called without a OneSignal ID, it stores the subscription ID for later retry.
66+
67+
Scenario:
68+
- User calls initialize() and login() early in app startup
69+
- Push subscription ID is available but OneSignal ID is not yet set
70+
- IAM fetch should be deferred
71+
72+
Expected:
73+
- shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID
74+
- No IAM fetch actually occurs
75+
*/
76+
func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws {
77+
/* Setup */
78+
let client = MockOneSignalClient()
79+
OneSignalCoreImpl.setSharedClient(client)
80+
81+
// Note: nothing has set OneSignal ID
82+
83+
/* Execute */
84+
OneSignalInAppMessages.getFromServer(testSubscriptionId)
85+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
86+
87+
/* Verify */
88+
// The controller should have stored the subscription ID for retry
89+
let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")
90+
XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId)
91+
92+
// Verify no IAM request was actually made (since we don't have OneSignal ID)
93+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0))
94+
}
95+
96+
/**
97+
Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch.
98+
99+
Scenario:
100+
- IAM fetch was previously deferred due to missing OneSignal ID
101+
- User response is returned and OneSignal ID becomes available
102+
- User state change observer is triggered
103+
104+
Expected:
105+
- IAM fetch is retried with the stored subscription ID
106+
- shouldFetchOnUserChangeWithSubscriptionID is cleared
107+
*/
108+
func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws {
109+
/* Setup */
110+
let client = MockOneSignalClient()
111+
OneSignalCoreImpl.setSharedClient(client)
112+
OSMessagingController.start()
113+
let controller = OSMessagingController.sharedInstance()
114+
115+
// Unblock consistency manager
116+
ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId)
117+
118+
/* Execute */
119+
// Setup the anonymous user
120+
MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId)
121+
OneSignalUserManagerImpl.sharedInstance.start()
122+
123+
// Login to a new user, without setting the client response, so onesignal ID is not hydrated
124+
OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil)
125+
126+
// First attempt: Try to fetch IAMs without OneSignal ID (should be deferred)
127+
OneSignalInAppMessages.getFromServer(testSubscriptionId)
128+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
129+
130+
// Verify the subscription ID was stored and no IAM fetch occurred
131+
XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId)
132+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0))
133+
134+
// Now let the login succeed, receive onesignal ID which fires user state observer
135+
MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId)
136+
OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.first?.sentToClient = false
137+
OneSignalUserManagerImpl.sharedInstance.userExecutor?.executePendingRequests()
138+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
139+
140+
/* Verify */
141+
// The fetch should have been retried now that OneSignal ID is available
142+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
143+
144+
// The stored subscription ID should be cleared after successful retry
145+
XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID"))
146+
}
147+
148+
/**
149+
Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing.
150+
151+
Scenario:
152+
- Normal user state change occurs
153+
- No deferred fetch was pending
154+
155+
Expected:
156+
- No retry logic is triggered
157+
- Normal operation continues
158+
*/
159+
func testDoesNothingWhenNoRetryPending() throws {
160+
/* Setup */
161+
let client = MockOneSignalClient()
162+
OneSignalCoreImpl.setSharedClient(client)
163+
OneSignalInAppMessages.start()
164+
let controller = OSMessagingController.sharedInstance()
165+
166+
// Set up user with valid OneSignal ID from the start
167+
MockUserRequests.setDefaultCreateAnonUserResponses(
168+
with: client,
169+
onesignalId: testOneSignalId,
170+
subscriptionId: testSubscriptionId
171+
)
172+
ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId)
173+
OneSignalUserManagerImpl.sharedInstance.start()
174+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
175+
176+
/* Verify */
177+
// IAM is fetched and no retry is pending
178+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
179+
XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID"))
180+
181+
/* Execute */
182+
// Trigger a normal user state change by login
183+
MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId)
184+
OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil)
185+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
186+
187+
/* Verify */
188+
// Does not fetch IAMs again
189+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
190+
}
191+
}

iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
#import "OSInAppMessageInternal.h"
66
#import "OSMessagingController.h"
7+
#import "OSInAppMessagingRequests.h"
78

89
// Expose private properties and methods for testing
910
@interface OSMessagingController (Testing)
1011
@property (strong, nonatomic, nonnull) NSMutableArray <OSInAppMessageInternal *> *messageDisplayQueue;
12+
+ (void)start;
13+
+ (void)removeInstance;
1114
- (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message;
1215
@end

iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserRequests.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,11 @@ extension MockUserRequests {
7676
}
7777

7878
@objc
79-
public static func setDefaultCreateAnonUserResponses(with client: MockOneSignalClient) {
80-
let anonCreateResponse = testDefaultFullCreateUserResponse(onesignalId: anonUserOSID, externalId: nil, subscriptionId: testPushSubId)
79+
public static func setDefaultCreateAnonUserResponses(with client: MockOneSignalClient, onesignalId: String? = nil, subscriptionId: String? = nil) {
80+
let anonCreateResponse = testDefaultFullCreateUserResponse(
81+
onesignalId: onesignalId ?? anonUserOSID,
82+
externalId: nil,
83+
subscriptionId: subscriptionId ?? testPushSubId)
8184

8285
client.setMockResponseForRequest(
8386
request: "<OSRequestCreateUser with external_id: nil>",

iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ - (void)testSendPurchases {
3737
[OneSignalUserManagerImpl.sharedInstance start];
3838

3939
// 1. Set up mock responses for the anonymous user
40-
[MockUserRequests setDefaultCreateAnonUserResponsesWith:client];
40+
[MockUserRequests setDefaultCreateAnonUserResponsesWith:client onesignalId:nil subscriptionId:nil];
4141
[OneSignalCoreImpl setSharedClient:client];
4242

4343
/* When */

0 commit comments

Comments
 (0)