Skip to content

Commit 28f5639

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

File tree

7 files changed

+216
-5
lines changed

7 files changed

+216
-5
lines changed

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# in tests, we may want to force cast and throw any errors
2+
disabled_rules:
3+
- force_cast
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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+
OSMessagingController.removeInstance()
55+
56+
// Set up basic configuration
57+
OneSignalConfigManager.setAppId(testAppId)
58+
OneSignalLog.setLogLevel(.LL_VERBOSE)
59+
}
60+
61+
override func tearDownWithError() throws {
62+
OSMessagingController.removeInstance()
63+
}
64+
65+
/**
66+
Test that when getInAppMessagesFromServer is called without a OneSignal ID, it stores the subscription ID for later retry.
67+
68+
Scenario:
69+
- User calls initialize() and login() early in app startup
70+
- Push subscription ID is available but OneSignal ID is not yet set
71+
- IAM fetch should be deferred
72+
73+
Expected:
74+
- shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID
75+
- No IAM fetch actually occurs
76+
*/
77+
func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws {
78+
/* Setup */
79+
let client = MockOneSignalClient()
80+
OneSignalCoreImpl.setSharedClient(client)
81+
82+
// Note: nothing has set OneSignal ID
83+
84+
/* Execute */
85+
OneSignalInAppMessages.getFromServer(testSubscriptionId)
86+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
87+
88+
/* Verify */
89+
// The controller should have stored the subscription ID for retry
90+
let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")
91+
XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId)
92+
93+
// Verify no IAM request was actually made (since we don't have OneSignal ID)
94+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0))
95+
}
96+
97+
/**
98+
Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch.
99+
100+
Scenario:
101+
- IAM fetch was previously deferred due to missing OneSignal ID
102+
- User response is returned and OneSignal ID becomes available
103+
- User state change observer is triggered
104+
105+
Expected:
106+
- IAM fetch is retried with the stored subscription ID
107+
- shouldFetchOnUserChangeWithSubscriptionID is cleared
108+
*/
109+
func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws {
110+
/* Setup */
111+
let client = MockOneSignalClient()
112+
OneSignalCoreImpl.setSharedClient(client)
113+
OSMessagingController.start()
114+
let controller = OSMessagingController.sharedInstance()
115+
116+
// Unblock consistency manager
117+
ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId)
118+
119+
/* Execute */
120+
// Setup the anonymous user
121+
MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId)
122+
OneSignalUserManagerImpl.sharedInstance.start()
123+
124+
// Login to a new user, without setting the client response, so onesignal ID is not hydrated
125+
OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil)
126+
127+
// First attempt: Try to fetch IAMs without OneSignal ID (should be deferred)
128+
OneSignalInAppMessages.getFromServer(testSubscriptionId)
129+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
130+
131+
// Verify the subscription ID was stored and no IAM fetch occurred
132+
XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId)
133+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0))
134+
135+
// Now let the login succeed, receive onesignal ID which fires user state observer
136+
MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId)
137+
OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.first?.sentToClient = false
138+
OneSignalUserManagerImpl.sharedInstance.userExecutor?.executePendingRequests()
139+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
140+
141+
/* Verify */
142+
// The fetch should have been retried now that OneSignal ID is available
143+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
144+
145+
// The stored subscription ID should be cleared after successful retry
146+
XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID"))
147+
}
148+
149+
/**
150+
Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing.
151+
152+
Scenario:
153+
- Normal user state change occurs
154+
- No deferred fetch was pending
155+
156+
Expected:
157+
- No retry logic is triggered
158+
- Normal operation continues
159+
*/
160+
func testDoesNothingWhenNoRetryPending() throws {
161+
/* Setup */
162+
let client = MockOneSignalClient()
163+
OneSignalCoreImpl.setSharedClient(client)
164+
OneSignalInAppMessages.start()
165+
let controller = OSMessagingController.sharedInstance()
166+
167+
// Set up user with valid OneSignal ID from the start
168+
MockUserRequests.setDefaultCreateAnonUserResponses(
169+
with: client,
170+
onesignalId: testOneSignalId,
171+
subscriptionId: testSubscriptionId
172+
)
173+
ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId)
174+
OneSignalUserManagerImpl.sharedInstance.start()
175+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
176+
177+
/* Verify */
178+
// IAM is fetched and no retry is pending
179+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
180+
XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID"))
181+
182+
/* Execute */
183+
// Trigger a normal user state change by login
184+
MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId)
185+
OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil)
186+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
187+
188+
/* Verify */
189+
// Does not fetch IAMs again
190+
XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1))
191+
}
192+
}

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)