From 6a46c9d6140a348abbd7889e9f3f11372291698a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 20 Jan 2026 01:36:04 -0800 Subject: [PATCH 1/2] feat: IAMs now display when triggers added before first fetch - Addresses issue where in-app messages wouldn't display on cold starts if their triggers were added very early (before IAM fetch completed). - Tracks triggers added before first fetch completes, then applies the isTriggerChanged flag to matching messages when they are received from the server, ensuring redisplay logic works correctly. --- .../Controller/OSMessagingController.m | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index d90f35d0a..45e14b641 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -149,6 +149,12 @@ @interface OSMessagingController () /// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet @property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID; +/// Tracks whether the first IAM fetch has completed since this cold start +@property (nonatomic) BOOL hasCompletedFirstFetch; + +/// Tracks trigger keys added early on cold start (before first fetch completes), for redisplay logic +@property (strong, nonatomic, nonnull) NSMutableSet *earlySessionTriggers; + @end @implementation OSMessagingController @@ -218,6 +224,8 @@ - (instancetype)init { self.messageDisplayQueue = [NSMutableArray new]; self.clickListeners = [NSMutableArray new]; self.lifecycleListeners = [NSMutableArray new]; + self.hasCompletedFirstFetch = NO; + self.earlySessionTriggers = [NSMutableSet new]; let standardUserDefaults = OneSignalUserDefaults.initStandard; @@ -404,6 +412,23 @@ - (void)updateInAppMessagesFromServer:(NSArray *)newMe self.messages = newMessages; self.calledLoadTags = NO; [self resetRedisplayMessagesBySession]; + + // Apply isTriggerChanged for messages that match triggers added too early on cold start + if (self.earlySessionTriggers.count > 0) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Processing triggers added early on cold start: %@", self.earlySessionTriggers]]; + for (OSInAppMessageInternal *message in self.messages) { + if ([self.redisplayedInAppMessages objectForKey:message.messageId] && + [self.triggerController hasSharedTriggers:message newTriggersKeys:self.earlySessionTriggers.allObjects]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Setting isTriggerChanged=YES for message %@", message]]; + message.isTriggerChanged = YES; + } + } + [self.earlySessionTriggers removeAllObjects]; + } + + // Mark that first fetch has completed + self.hasCompletedFirstFetch = YES; + [self evaluateMessages]; [self deleteOldRedisplayedInAppMessages]; } @@ -806,6 +831,13 @@ - (void)evaluateRedisplayedInAppMessages:(NSArray *)newTriggersKeys #pragma mark Trigger Methods - (void)addTriggers:(NSDictionary *)triggers { [self evaluateRedisplayedInAppMessages:triggers.allKeys]; + + // Track triggers added early on cold start (before first fetch completes) for redisplay logic + if (!self.hasCompletedFirstFetch) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", triggers]]; + [self.earlySessionTriggers addObjectsFromArray:triggers.allKeys]; + } + [self.triggerController addTriggers:triggers]; } From c50dee39ccb7bb09be37b0f2ff5b7f4fd289bede Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 21 Jan 2026 22:46:19 -0800 Subject: [PATCH 2/2] test: add tests for early trigger tracking feature Add comprehensive test coverage for the early trigger tracking feature that enables IAMs to display when triggers are added before the first fetch completes on cold start. --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../EarlyTriggerTrackingTests.swift | 456 ++++++++++++++++++ .../OSMessagingControllerUserStateTests.swift | 2 +- ...SignalInAppMessagesTests-Bridging-Header.h | 5 + 4 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 95c0432cd..e5f5cb53f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ 3C2D8A5928B4C4E300BE41F6 /* OSDelta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2D8A5828B4C4E300BE41F6 /* OSDelta.swift */; }; 3C2DB2F12DE6CB5E0006B905 /* OneSignalBadgeHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3C2DB2F22DE6CB5E0006B905 /* OneSignalBadgeHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */; }; + 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 */; }; 3C44673E296D099D0039A49E /* OneSignalMobileProvision.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411FD1E73342200E41FD7 /* OneSignalMobileProvision.m */; }; @@ -1310,6 +1311,7 @@ 3C2D8A5828B4C4E300BE41F6 /* OSDelta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDelta.swift; sourceTree = ""; }; 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalBadgeHelpers.h; sourceTree = ""; }; 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalBadgeHelpers.m; sourceTree = ""; }; + 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 = ""; }; 3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSBackgroundTaskHandlerImpl.h; sourceTree = ""; }; @@ -2168,6 +2170,7 @@ 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */, 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */, 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */, + 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, ); @@ -4313,6 +4316,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */, 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */, 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift new file mode 100644 index 000000000..474e4d5a7 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -0,0 +1,456 @@ +/* + 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 +import OneSignalInAppMessagesMocks +// @testable import OneSignalUser +// @testable import OneSignalInAppMessages + +/** + Tests for early trigger tracking functionality. + + These tests verify that in-app messages can be displayed on cold starts when their + triggers are added very early (before IAM fetch completes). + */ +final class EarlyTriggerTrackingTests: XCTestCase { + + private let testSubscriptionId = "test-subscription-id-12345" + private let testOneSignalId = "test-onesignal-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 hasCompletedFirstFetch is set to true after the first fetch completes. + + Scenario: + - Fresh start with no previous fetch + - First IAM fetch completes + + Expected: + - hasCompletedFirstFetch changes from false to true + */ + func testHasCompletedFirstFetch_isSetAfterFirstFetch() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Verify initial state + XCTAssertFalse(controller.hasCompletedFirstFetch) + + // Set up mock responses + MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + + let response = IAMTestHelpers.testFetchMessagesResponse(messages: []) + client.setMockResponseForRequest( + request: "", + response: response) + + /* Execute */ + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Verify */ + XCTAssertTrue(controller.hasCompletedFirstFetch) + } + + /** + Test that triggers added before the first fetch completes are tracked in earlySessionTriggers. + + Scenario: + - Cold start, no IAM fetch has completed yet + - Triggers are added very early in the app lifecycle + + Expected: + - hasCompletedFirstFetch is false + - Triggers are added to earlySessionTriggers + */ + func testTriggersAddedBeforeFirstFetch_areTrackedInEarlySessionTriggers() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Verify initial state + XCTAssertFalse(controller.hasCompletedFirstFetch) + XCTAssertEqual(controller.earlySessionTriggers.count, 0) + + /* Execute */ + // Add triggers before first fetch completes + controller.addTriggers(["trigger1": "value1", "trigger2": "value2"]) + + /* Verify */ + XCTAssertFalse(controller.hasCompletedFirstFetch) + XCTAssertEqual(controller.earlySessionTriggers.count, 2) + XCTAssertTrue(controller.earlySessionTriggers.contains("trigger1")) + XCTAssertTrue(controller.earlySessionTriggers.contains("trigger2")) + } + + /** + Test that multiple calls to addTriggers before first fetch accumulate in earlySessionTriggers. + + Scenario: + - Multiple trigger additions before first fetch completes + + Expected: + - All trigger keys are accumulated in earlySessionTriggers + */ + func testMultipleTriggersAddedBeforeFirstFetch_areAllTracked() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + /* Execute */ + // Add triggers in multiple calls + controller.addTriggers(["trigger1": "value1"]) + controller.addTriggers(["trigger2": "value2"]) + controller.addTriggers(["trigger3": "value3"]) + + /* Verify */ + XCTAssertFalse(controller.hasCompletedFirstFetch, ) + XCTAssertEqual(controller.earlySessionTriggers.count, 3) + XCTAssertTrue(controller.earlySessionTriggers.contains("trigger1")) + XCTAssertTrue(controller.earlySessionTriggers.contains("trigger2")) + XCTAssertTrue(controller.earlySessionTriggers.contains("trigger3")) + } + + /** + Test that triggers added after the first fetch completes are NOT tracked in earlySessionTriggers. + + Scenario: + - First IAM fetch has completed + - New triggers are added + + Expected: + - hasCompletedFirstFetch is true + - Triggers are NOT added to earlySessionTriggers + */ + func testTriggersAddedAfterFirstFetch_areNotTrackedInEarlySessionTriggers() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Set up mock responses + MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + + // Set up IAM fetch response with an empty message list + let response = IAMTestHelpers.testFetchMessagesResponse(messages: []) + client.setMockResponseForRequest( + request: "", + response: response) + + // Start the SDK and trigger first fetch + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2.0) + + // Verify first fetch completed + XCTAssertTrue(controller.hasCompletedFirstFetch) + XCTAssertEqual(controller.earlySessionTriggers.count, 0) + + /* Execute */ + // Add triggers after first fetch + controller.addTriggers(["lateAction": "value"]) + + /* Verify */ + XCTAssertTrue(controller.hasCompletedFirstFetch) + XCTAssertEqual(controller.earlySessionTriggers.count, 0) + + // Verify the triggers were still added to the trigger controller + XCTAssertTrue(controller.triggerController.messageMatchesTriggers( + OSInAppMessageInternal.instance(withJson: IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "lateAction", + triggerId: "test_id", + type: 2, // equal + value: "value" + ))! + )) + } + + /** + Test that messages matching early triggers get isTriggerChanged flag set when received from server. + + Scenario: + - Triggers are added before first fetch + - IAM fetch completes with messages that were previously shown (in redisplayedInAppMessages) + - Some messages match the early triggers + + Expected: + - Messages matching early triggers have isTriggerChanged = true + - Messages not matching early triggers have isTriggerChanged = false + - earlySessionTriggers is cleared after processing + - hasCompletedFirstFetch is true after fetch + */ + func testMessagesMatchingEarlyTriggers_getIsTriggerChangedFlag() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Add triggers before first fetch + controller.addTriggers(["loginAction": "complete", "sessionStart": "true"]) + + // Verify triggers were tracked + XCTAssertEqual(controller.earlySessionTriggers.count, 2) + XCTAssertFalse(controller.hasCompletedFirstFetch) + + // Set up mock responses + MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + + // Create test messages: + // Message 1: Matches early trigger "loginAction" + let message1Json = IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "loginAction", + triggerId: "trigger1", + type: 2, // equal + value: "complete" + ) + let message1 = OSInAppMessageInternal.instance(withJson: message1Json)! + + // Message 2: Matches early trigger "sessionStart" + let message2Json = IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "sessionStart", + triggerId: "trigger2", + type: 2, // equal + value: "true" + ) + let message2 = OSInAppMessageInternal.instance(withJson: message2Json)! + + // Message 3: Does not match any early trigger + let message3Json = IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "otherAction", + triggerId: "trigger3", + type: 2, // equal + value: "something" + ) + let message3 = OSInAppMessageInternal.instance(withJson: message3Json)! + + // Mark messages 1 and 2 as previously displayed (so they're in redisplayedInAppMessages) + controller.redisplayedInAppMessages[message1.messageId] = message1 + controller.redisplayedInAppMessages[message2.messageId] = message2 + + // Set up IAM fetch response + let response = IAMTestHelpers.testFetchMessagesResponse(messages: [message1Json, message2Json, message3Json]) + client.setMockResponseForRequest( + request: "", + response: response) + + /* Execute */ + // Start the SDK and trigger first fetch + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2.0) + + /* Verify */ + // First fetch should have completed + XCTAssertTrue(controller.hasCompletedFirstFetch) + + // Early triggers should be cleared + XCTAssertEqual(controller.earlySessionTriggers.count, 0) + + // Messages should be received + XCTAssertEqual(controller.messages.count, 3) + + // Cast to properly typed array and find the messages + let messages = controller.messages as! [OSInAppMessageInternal] + let receivedMessage1 = messages.first { $0.messageId == message1.messageId } + let receivedMessage2 = messages.first { $0.messageId == message2.messageId } + let receivedMessage3 = messages.first { $0.messageId == message3.messageId } + + XCTAssertNotNil(receivedMessage1) + XCTAssertNotNil(receivedMessage2) + XCTAssertNotNil(receivedMessage3) + + // Messages matching early triggers and in redisplayedInAppMessages should have isTriggerChanged = true + XCTAssertTrue(receivedMessage1!.isTriggerChanged) + XCTAssertTrue(receivedMessage2!.isTriggerChanged) + + // Message 3 does not match early triggers (even though not in redisplayedInAppMessages anyway) + XCTAssertFalse(receivedMessage3!.isTriggerChanged) + } + + /** + Test that messages NOT in redisplayedInAppMessages don't get isTriggerChanged flag, even if they match early triggers. + + Scenario: + - Triggers are added before first fetch + - IAM fetch completes with messages that were NOT previously shown + - Messages match the early triggers + + Expected: + - Messages should NOT have isTriggerChanged = true (because they weren't in redisplayedInAppMessages) + */ + func testMessagesNotInRedisplayed_dontGetIsTriggerChangedFlag() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Add triggers before first fetch + controller.addTriggers(["newTrigger": "value"]) + + // Set up mock responses + MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + + // Create a test message that matches the early trigger + let messageJson = IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "newTrigger", + triggerId: "trigger1", + type: 2, // equal + value: "value" + ) + let message = OSInAppMessageInternal.instance(withJson: messageJson)! + + // Do NOT add this message to redisplayedInAppMessages + + // Set up IAM fetch response + let response = IAMTestHelpers.testFetchMessagesResponse(messages: [messageJson]) + client.setMockResponseForRequest( + request: "", + response: response) + + /* Execute */ + // Start the SDK and trigger first fetch + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1.0) + + /* Verify */ + XCTAssertTrue(controller.hasCompletedFirstFetch) + XCTAssertEqual(controller.messages.count, 1) + + let messages = controller.messages as! [OSInAppMessageInternal] + let receivedMessage = messages.first { $0.messageId == message.messageId } + XCTAssertNotNil(receivedMessage) + + // Message should NOT have isTriggerChanged because it's not in redisplayedInAppMessages + XCTAssertFalse(receivedMessage!.isTriggerChanged) + } + + /** + Test that earlySessionTriggers only applies to messages with shared trigger keys. + + Scenario: + - Multiple triggers added early + - Messages with different trigger combinations + + Expected: + - Only messages with matching trigger keys get isTriggerChanged + */ + func testIsTriggerChanged_onlyAppliedToMessagesWithMatchingTriggerKeys() throws { + /* Setup */ + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OSMessagingController.start() + let controller = OSMessagingController.sharedInstance() + + // Add specific triggers before first fetch + controller.addTriggers(["action_a": "value1"]) + + // Set up mock responses + MockUserRequests.setDefaultCreateAnonUserResponses(with: client, onesignalId: testOneSignalId, subscriptionId: testSubscriptionId) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + + // Message 1: Uses trigger "action_a" (matches early triggers) + let message1Json = IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "action_a", + triggerId: "trigger1", + type: 2, + value: "value1" + ) + let message1 = OSInAppMessageInternal.instance(withJson: message1Json)! + + // Message 2: Uses trigger "action_b" (does NOT match early triggers) + let message2Json = IAMTestHelpers.testMessageJsonWithTrigger( + kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, + property: "action_b", + triggerId: "trigger2", + type: 2, + value: "value2" + ) + let message2 = OSInAppMessageInternal.instance(withJson: message2Json)! + + // Mark both as previously displayed + controller.redisplayedInAppMessages[message1.messageId] = message1 + controller.redisplayedInAppMessages[message2.messageId] = message2 + + // Set up IAM fetch response + let response = IAMTestHelpers.testFetchMessagesResponse(messages: [message1Json, message2Json]) + client.setMockResponseForRequest( + request: "", + response: response) + + /* Execute */ + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1.0) + + /* Verify */ + let messages = controller.messages as! [OSInAppMessageInternal] + let receivedMessage1 = messages.first { $0.messageId == message1.messageId } + let receivedMessage2 = messages.first { $0.messageId == message2.messageId } + + XCTAssertNotNil(receivedMessage1) + XCTAssertNotNil(receivedMessage2) + + // Only message 1 should have isTriggerChanged (it has shared trigger "action_a") + XCTAssertTrue(receivedMessage1!.isTriggerChanged) + XCTAssertFalse(receivedMessage2!.isTriggerChanged) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index bfc661d7c..e4ec5585d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -52,7 +52,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalUserMocks.reset() OSConsistencyManager.shared.reset() OSMessagingController.removeInstance() - + // Set up basic configuration OneSignalConfigManager.setAppId(testAppId) OneSignalLog.setLogLevel(.LL_VERBOSE) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index c0f9be022..813316635 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -8,7 +8,12 @@ // Expose private properties and methods for testing @interface OSMessagingController (Testing) +@property (nonatomic) BOOL hasCompletedFirstFetch; @property (strong, nonatomic, nonnull) NSMutableArray *messageDisplayQueue; +@property (strong, nonatomic, nonnull) NSMutableSet *earlySessionTriggers; +@property (strong, nonatomic, nonnull) NSMutableDictionary *redisplayedInAppMessages; +@property (strong, nonatomic, nonnull) NSMutableArray *messages; +@property (strong, nonatomic, nonnull) OSTriggerController *triggerController; + (void)start; + (void)removeInstance; - (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message;