From 8bbe4d0ef51e6380fdce135e76509d99aa91ccec Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 17 Nov 2025 13:19:51 -0800 Subject: [PATCH 1/7] add notifications tests --- lib/src/notifications.dart | 11 +- test/mock_channel.dart | 28 +++ test/notifications_test.dart | 465 +++++++++++++++++++++++++++++++++++ 3 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 test/notifications_test.dart diff --git a/lib/src/notifications.dart b/lib/src/notifications.dart index 180a1e3a..bc4d4fb0 100644 --- a/lib/src/notifications.dart +++ b/lib/src/notifications.dart @@ -1,5 +1,6 @@ import 'dart:async'; -import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:onesignal_flutter/src/defines.dart'; import 'package:onesignal_flutter/src/notification.dart'; @@ -45,7 +46,7 @@ class OneSignalNotifications { /// provisional, // only available in iOS 12 /// ephemeral, // only available in iOS 14 Future permissionNative() async { - if (Platform.isIOS) { + if (defaultTargetPlatform == TargetPlatform.iOS) { return OSNotificationPermission .values[await _channel.invokeMethod("OneSignal#permissionNative")]; } else { @@ -63,7 +64,7 @@ class OneSignalNotifications { /// Removes a single notification. Future removeNotification(int notificationId) async { - if (Platform.isAndroid) { + if (defaultTargetPlatform == TargetPlatform.android) { return await _channel.invokeMethod( "OneSignal#removeNotification", {'notificationId': notificationId}); } @@ -71,7 +72,7 @@ class OneSignalNotifications { /// Removes a grouped notification. Future removeGroupedNotifications(String notificationGroup) async { - if (Platform.isAndroid) { + if (defaultTargetPlatform == TargetPlatform.android) { return await _channel.invokeMethod("OneSignal#removeGroupedNotifications", {'notificationGroup': notificationGroup}); } @@ -93,7 +94,7 @@ class OneSignalNotifications { /// your app can request provisional authorization. Future registerForProvisionalAuthorization( bool fallbackToSettings) async { - if (Platform.isIOS) { + if (defaultTargetPlatform == TargetPlatform.iOS) { return await _channel .invokeMethod("OneSignal#registerForProvisionalAuthorization"); } else { diff --git a/test/mock_channel.dart b/test/mock_channel.dart index b1be7569..d3a90bc0 100644 --- a/test/mock_channel.dart +++ b/test/mock_channel.dart @@ -173,6 +173,27 @@ class OneSignalMockChannelController { state.preventedNotificationId = (call.arguments as Map)['notificationId'] as String?; break; + case "OneSignal#removeNotification": + state.removedNotificationId = + (call.arguments as Map)['notificationId'] as int?; + break; + case "OneSignal#removeGroupedNotifications": + state.removedNotificationGroup = (call.arguments + as Map)['notificationGroup'] as String?; + break; + case "OneSignal#clearAll": + state.clearedAllNotifications = true; + break; + case "OneSignal#permission": + return state.notificationPermission ?? false; + case "OneSignal#canRequest": + return state.canRequestPermission ?? false; + case "OneSignal#addNativeClickListener": + state.nativeClickListenerAdded = true; + break; + case "OneSignal#proceedWithWillDisplay": + state.proceedWithWillDisplayCalled = true; + break; } } } @@ -233,6 +254,13 @@ class OneSignalState { Map? postNotificationJson; String? displayedNotificationId; String? preventedNotificationId; + int? removedNotificationId; + String? removedNotificationGroup; + bool? clearedAllNotifications; + bool? notificationPermission; + bool? canRequestPermission; + bool? nativeClickListenerAdded; + bool? proceedWithWillDisplayCalled; /* All of the following functions parse the MethodCall diff --git a/test/notifications_test.dart b/test/notifications_test.dart new file mode 100644 index 00000000..d491777f --- /dev/null +++ b/test/notifications_test.dart @@ -0,0 +1,465 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:onesignal_flutter/src/defines.dart'; +import 'package:onesignal_flutter/src/notification.dart'; +import 'package:onesignal_flutter/src/notifications.dart'; + +import 'mock_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('OneSignalNotifications', () { + late OneSignalNotifications notifications; + late OneSignalMockChannelController channelController; + + setUp(() { + channelController = OneSignalMockChannelController(); + channelController.resetState(); + notifications = OneSignalNotifications(); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + group('permission', () { + test('returns initial false value', () { + expect(notifications.permission, false); + }); + + test('permission gets updated via observer during lifecycleInit', + () async { + channelController.state.notificationPermission = false; + await notifications.lifecycleInit(); + expect(notifications.permission, false); + + // Test that the observer added in lifecycleInit updates _permission + notifications.onNotificationPermissionDidChange(true); + expect(notifications.permission, true); + + notifications.onNotificationPermissionDidChange(false); + expect(notifications.permission, false); + }); + }); + + group('permissionNative', () { + test('returns authorized when permission is true on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + channelController.state.notificationPermission = true; + await notifications.lifecycleInit(); + final permission = await notifications.permissionNative(); + expect(permission, OSNotificationPermission.authorized); + }); + + test('returns denied when permission is false on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final permission = await notifications.permissionNative(); + expect(permission, OSNotificationPermission.denied); + }); + }); + + group('canRequest', () { + test('invokes OneSignal#canRequest method', () async { + final result = await notifications.canRequest(); + expect(result, isA()); + }); + }); + + group('removeNotification', () { + test('invokes OneSignal#removeNotification on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + const notificationId = 123; + await notifications.removeNotification(notificationId); + expect(channelController.state.removedNotificationId, notificationId); + }); + + test('does nothing on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const notificationId = 123; + await notifications.removeNotification(notificationId); + expect(true, true); + }); + }); + + group('removeGroupedNotifications', () { + test('invokes OneSignal#removeGroupedNotifications on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + const notificationGroup = 'test_group'; + await notifications.removeGroupedNotifications(notificationGroup); + expect(channelController.state.removedNotificationGroup, + notificationGroup); + }); + + test('does nothing on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const notificationGroup = 'test_group'; + await notifications.removeGroupedNotifications(notificationGroup); + expect(true, true); + }); + }); + + group('clearAll', () { + test('invokes OneSignal#clearAll method', () async { + await notifications.clearAll(); + expect(channelController.state.clearedAllNotifications, true); + }); + }); + + group('requestPermission', () { + test('invokes OneSignal#requestPermission with fallbackToSettings true', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('OneSignal#notifications'), (call) async { + if (call.method == 'OneSignal#requestPermission') { + final args = call.arguments as Map; + expect(args['fallbackToSettings'], true); + return true; + } + return null; + }); + + await notifications.requestPermission(true); + }); + + test('invokes OneSignal#requestPermission with fallbackToSettings false', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('OneSignal#notifications'), (call) async { + if (call.method == 'OneSignal#requestPermission') { + final args = call.arguments as Map; + expect(args['fallbackToSettings'], false); + return true; + } + return null; + }); + + await notifications.requestPermission(false); + }); + }); + + group('registerForProvisionalAuthorization', () { + test('invokes method on iOS only', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('OneSignal#notifications'), (call) async { + if (call.method == 'OneSignal#registerForProvisionalAuthorization') { + return true; + } + return null; + }); + + final result = + await notifications.registerForProvisionalAuthorization(true); + expect(result, isA()); + }); + + test('returns false on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final result = + await notifications.registerForProvisionalAuthorization(true); + expect(result, false); + }); + }); + + group('Permission Observers', () { + test('addPermissionObserver adds observer to list', () { + var called = false; + notifications.addPermissionObserver((permission) { + called = true; + }); + notifications.onNotificationPermissionDidChange(true); + expect(called, true); + }); + + test('removePermissionObserver removes observer from list', () { + var called = false; + void observer(bool permission) { + called = true; + } + + notifications.addPermissionObserver(observer); + notifications.removePermissionObserver(observer); + + notifications.onNotificationPermissionDidChange(true); + expect(called, false); + }); + + test('multiple permission observers are all called', () { + var observer1Called = false; + var observer2Called = false; + + notifications.addPermissionObserver((permission) { + observer1Called = true; + expect(permission, true); + }); + notifications.addPermissionObserver((permission) { + observer2Called = true; + expect(permission, true); + }); + + notifications.onNotificationPermissionDidChange(true); + + expect(observer1Called, true); + expect(observer2Called, true); + }); + + test('observers receive correct permission value', () { + bool? receivedPermission; + + notifications.addPermissionObserver((permission) { + receivedPermission = permission; + }); + + notifications.onNotificationPermissionDidChange(true); + expect(receivedPermission, true); + + notifications.onNotificationPermissionDidChange(false); + expect(receivedPermission, false); + }); + }); + + group('lifecycleInit', () { + test('initializes permission and calls lifecycleInit', () async { + channelController.state.notificationPermission = true; + await notifications.lifecycleInit(); + expect(notifications.permission, true); + }); + }); + + group('Click Listeners', () { + test('addClickListener registers native click listener on first add', () { + void listener(OSNotificationClickEvent event) {} + notifications.addClickListener(listener); + expect(channelController.state.nativeClickListenerAdded, true); + }); + + test('addClickListener registers native listener only once', () { + void listener1(OSNotificationClickEvent event) {} + void listener2(OSNotificationClickEvent event) {} + + notifications.addClickListener(listener1); + notifications.addClickListener(listener2); + + expect(channelController.state.nativeClickListenerAdded, true); + }); + + test('removeClickListener removes listener', () { + var listenerCalled = false; + void testListener(OSNotificationClickEvent event) { + listenerCalled = true; + } + + notifications.addClickListener(testListener); + notifications.removeClickListener(testListener); + + expect(listenerCalled, false); + }); + + test('adding same listener multiple times adds multiple entries', () { + void listener(OSNotificationClickEvent event) {} + + notifications.addClickListener(listener); + notifications.addClickListener(listener); + + // Both listeners should be in the list + expect(true, true); + }); + + test('removing listener only removes first occurrence', () { + void listener(OSNotificationClickEvent event) {} + + notifications.addClickListener(listener); + notifications.addClickListener(listener); + notifications.removeClickListener(listener); + + // One listener should still be in the list + expect(true, true); + }); + }); + + group('Will Display Listeners', () { + test('addForegroundWillDisplayListener adds listener', () { + var listenerCalled = false; + void listener(OSNotificationWillDisplayEvent event) { + listenerCalled = true; + } + + notifications.addForegroundWillDisplayListener(listener); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('OneSignal#notifications'), (call) async { + if (call.method == 'OneSignal#onWillDisplayNotification') { + listener(OSNotificationWillDisplayEvent( + (call.arguments as Map))); + } + return null; + }); + + expect(listenerCalled, false); + }); + + test('removeForegroundWillDisplayListener removes listener', () { + var listenerCalled = false; + void listener(OSNotificationWillDisplayEvent event) { + listenerCalled = true; + } + + notifications.addForegroundWillDisplayListener(listener); + notifications.removeForegroundWillDisplayListener(listener); + + expect(listenerCalled, false); + }); + + test('multiple will display listeners can be added', () { + void listener1(OSNotificationWillDisplayEvent event) {} + void listener2(OSNotificationWillDisplayEvent event) {} + + notifications.addForegroundWillDisplayListener(listener1); + notifications.addForegroundWillDisplayListener(listener2); + + expect(true, true); + }); + + test('can remove specific will display listener', () { + void listener1(OSNotificationWillDisplayEvent event) {} + void listener2(OSNotificationWillDisplayEvent event) {} + + notifications.addForegroundWillDisplayListener(listener1); + notifications.addForegroundWillDisplayListener(listener2); + notifications.removeForegroundWillDisplayListener(listener1); + + expect(true, true); + }); + }); + + group('preventDefault', () { + test('invokes OneSignal#preventDefault with notificationId', () { + const notificationId = 'test-notification-id'; + notifications.preventDefault(notificationId); + + expect(channelController.state.preventedNotificationId, notificationId); + }); + }); + + group('displayNotification', () { + test('invokes OneSignal#displayNotification with notificationId', () { + const notificationId = 'test-notification-id'; + notifications.displayNotification(notificationId); + + expect(channelController.state.displayedNotificationId, notificationId); + }); + }); + + group('onNotificationPermissionDidChange', () { + test('calls all registered permission observers with permission value', + () { + var observer1PermissionValue; + var observer2PermissionValue; + + notifications.addPermissionObserver((permission) { + observer1PermissionValue = permission; + }); + notifications.addPermissionObserver((permission) { + observer2PermissionValue = permission; + }); + + notifications.onNotificationPermissionDidChange(true); + + expect(observer1PermissionValue, true); + expect(observer2PermissionValue, true); + }); + + test('observers can be called multiple times with different values', () { + final receivedValues = []; + + notifications.addPermissionObserver((permission) { + receivedValues.add(permission); + }); + + notifications.onNotificationPermissionDidChange(true); + notifications.onNotificationPermissionDidChange(false); + notifications.onNotificationPermissionDidChange(true); + + expect(receivedValues, [true, false, true]); + }); + }); + + group('Edge Cases', () { + test('permission state is maintained across observer calls', () async { + channelController.state.notificationPermission = true; + await notifications.lifecycleInit(); + expect(notifications.permission, true); + + notifications.addPermissionObserver((permission) { + expect(permission, isNotNull); + }); + + expect(notifications.permission, true); + }); + + test('adding and removing permission observers works correctly', () { + final callLog = []; + + void observer1(bool permission) { + callLog.add(permission); + } + + void observer2(bool permission) { + callLog.add(permission); + } + + notifications.addPermissionObserver(observer1); + notifications.addPermissionObserver(observer2); + notifications.onNotificationPermissionDidChange(true); + + expect(callLog.length, 2); + + callLog.clear(); + notifications.removePermissionObserver(observer1); + notifications.onNotificationPermissionDidChange(false); + + expect(callLog.length, 1); + expect(callLog[0], false); + }); + + test('click listener can be added and invoked multiple times', () { + void listener(OSNotificationClickEvent event) {} + + notifications.addClickListener(listener); + expect(true, true); + }); + + test('will display listener can be added and invoked multiple times', () { + void listener(OSNotificationWillDisplayEvent event) {} + + notifications.addForegroundWillDisplayListener(listener); + expect(true, true); + }); + + test('notification IDs are passed correctly through preventDefault', () { + final notificationIds = ['id-1', 'id-2', 'id-3']; + + for (final id in notificationIds) { + notifications.preventDefault(id); + expect(channelController.state.preventedNotificationId, id); + } + }); + + test('notification IDs are passed correctly through displayNotification', + () { + final notificationIds = ['id-1', 'id-2', 'id-3']; + + for (final id in notificationIds) { + notifications.displayNotification(id); + expect(channelController.state.displayedNotificationId, id); + } + }); + }); + }); +} From d6325aa3155a791238c8d78303a42b9c79fe52de Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 17 Nov 2025 15:02:40 -0800 Subject: [PATCH 2/7] improve notification tests --- lib/src/notifications.dart | 4 +- test/mock_channel.dart | 25 ++++- test/notifications_test.dart | 186 ++++++++++++++++------------------- 3 files changed, 109 insertions(+), 106 deletions(-) diff --git a/lib/src/notifications.dart b/lib/src/notifications.dart index bc4d4fb0..10aa38c1 100644 --- a/lib/src/notifications.dart +++ b/lib/src/notifications.dart @@ -26,7 +26,7 @@ class OneSignalNotifications { []; // constructor method OneSignalNotifications() { - this._channel.setMethodCallHandler(_handleMethod); + this._channel.setMethodCallHandler(handleMethod); } bool _permission = false; @@ -123,7 +123,7 @@ class OneSignalNotifications { return await _channel.invokeMethod("OneSignal#lifecycleInit"); } - Future _handleMethod(MethodCall call) async { + Future handleMethod(MethodCall call) async { if (call.method == 'OneSignal#onClickNotification') { for (var listener in _clickListeners) { listener( diff --git a/test/mock_channel.dart b/test/mock_channel.dart index d3a90bc0..8f96033a 100644 --- a/test/mock_channel.dart +++ b/test/mock_channel.dart @@ -95,8 +95,17 @@ class OneSignalMockChannelController { (call.arguments as Map)['language'] as String?; return {"success": true}; case "OneSignal#requestPermission": - state.locationPermissionRequested = true; - break; + // Location requestPermission (no arguments) + if (call.arguments == null) { + state.locationPermissionRequested = true; + break; + } + // Notifications requestPermission (with fallbackToSettings argument) + // Falls through to the notifications handler below + state.requestPermissionCalled = true; + state.requestPermissionFallbackToSettings = (call.arguments + as Map)['fallbackToSettings'] as bool?; + return true; case "OneSignal#setShared": state.locationShared = call.arguments as bool?; break; @@ -186,10 +195,16 @@ class OneSignalMockChannelController { break; case "OneSignal#permission": return state.notificationPermission ?? false; + case "OneSignal#permissionNative": + return state.notificationPermissionNative ?? 1; // 1 = denied case "OneSignal#canRequest": return state.canRequestPermission ?? false; + case "OneSignal#registerForProvisionalAuthorization": + state.registerForProvisionalAuthorizationCalled = true; + return true; case "OneSignal#addNativeClickListener": state.nativeClickListenerAdded = true; + state.nativeClickListenerAddedCount++; break; case "OneSignal#proceedWithWillDisplay": state.proceedWithWillDisplayCalled = true; @@ -258,8 +273,14 @@ class OneSignalState { String? removedNotificationGroup; bool? clearedAllNotifications; bool? notificationPermission; + int? + notificationPermissionNative; // 0 = notDetermined, 1 = denied, 2 = authorized, etc. bool? canRequestPermission; + bool? requestPermissionCalled; + bool? requestPermissionFallbackToSettings; + bool? registerForProvisionalAuthorizationCalled; bool? nativeClickListenerAdded; + int nativeClickListenerAddedCount = 0; bool? proceedWithWillDisplayCalled; /* diff --git a/test/notifications_test.dart b/test/notifications_test.dart index d491777f..1b002bcf 100644 --- a/test/notifications_test.dart +++ b/test/notifications_test.dart @@ -58,12 +58,38 @@ void main() { final permission = await notifications.permissionNative(); expect(permission, OSNotificationPermission.denied); }); + + test('returns authorized on iOS when native method returns authorized', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channelController.state.notificationPermissionNative = + OSNotificationPermission.authorized.index; + + final permission = await notifications.permissionNative(); + expect(permission, OSNotificationPermission.authorized); + }); + + test('returns denied on iOS when native method returns denied', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channelController.state.notificationPermissionNative = + OSNotificationPermission.denied.index; + + final permission = await notifications.permissionNative(); + expect(permission, OSNotificationPermission.denied); + }); }); group('canRequest', () { - test('invokes OneSignal#canRequest method', () async { + test('returns true when canRequestPermission is true', () async { + channelController.state.canRequestPermission = true; final result = await notifications.canRequest(); - expect(result, isA()); + expect(result, true); + }); + + test('returns false when canRequestPermission is false', () async { + channelController.state.canRequestPermission = false; + final result = await notifications.canRequest(); + expect(result, false); }); }); @@ -74,13 +100,6 @@ void main() { await notifications.removeNotification(notificationId); expect(channelController.state.removedNotificationId, notificationId); }); - - test('does nothing on iOS', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - const notificationId = 123; - await notifications.removeNotification(notificationId); - expect(true, true); - }); }); group('removeGroupedNotifications', () { @@ -91,13 +110,6 @@ void main() { expect(channelController.state.removedNotificationGroup, notificationGroup); }); - - test('does nothing on iOS', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - const notificationGroup = 'test_group'; - await notifications.removeGroupedNotifications(notificationGroup); - expect(true, true); - }); }); group('clearAll', () { @@ -110,52 +122,30 @@ void main() { group('requestPermission', () { test('invokes OneSignal#requestPermission with fallbackToSettings true', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('OneSignal#notifications'), (call) async { - if (call.method == 'OneSignal#requestPermission') { - final args = call.arguments as Map; - expect(args['fallbackToSettings'], true); - return true; - } - return null; - }); - await notifications.requestPermission(true); + expect(channelController.state.requestPermissionCalled, true); + expect( + channelController.state.requestPermissionFallbackToSettings, true); }); test('invokes OneSignal#requestPermission with fallbackToSettings false', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('OneSignal#notifications'), (call) async { - if (call.method == 'OneSignal#requestPermission') { - final args = call.arguments as Map; - expect(args['fallbackToSettings'], false); - return true; - } - return null; - }); - await notifications.requestPermission(false); + expect(channelController.state.requestPermissionCalled, true); + expect( + channelController.state.requestPermissionFallbackToSettings, false); }); }); group('registerForProvisionalAuthorization', () { test('invokes method on iOS only', () async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('OneSignal#notifications'), (call) async { - if (call.method == 'OneSignal#registerForProvisionalAuthorization') { - return true; - } - return null; - }); - final result = await notifications.registerForProvisionalAuthorization(true); - expect(result, isA()); + expect(result, true); + expect( + channelController.state.registerForProvisionalAuthorizationCalled, + true); }); test('returns false on Android', () async { @@ -246,6 +236,7 @@ void main() { notifications.addClickListener(listener2); expect(channelController.state.nativeClickListenerAdded, true); + expect(channelController.state.nativeClickListenerAddedCount, 1); }); test('removeClickListener removes listener', () { @@ -283,28 +274,28 @@ void main() { }); group('Will Display Listeners', () { - test('addForegroundWillDisplayListener adds listener', () { + final notificationData = { + 'notification': { + 'notificationId': 'test-id', + 'title': 'Test', + 'body': 'Test body' + } + }; + + test('addForegroundWillDisplayListener adds listener', () async { var listenerCalled = false; void listener(OSNotificationWillDisplayEvent event) { listenerCalled = true; } notifications.addForegroundWillDisplayListener(listener); + await notifications.handleMethod(MethodCall( + 'OneSignal#onWillDisplayNotification', notificationData)); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('OneSignal#notifications'), (call) async { - if (call.method == 'OneSignal#onWillDisplayNotification') { - listener(OSNotificationWillDisplayEvent( - (call.arguments as Map))); - } - return null; - }); - - expect(listenerCalled, false); + expect(listenerCalled, true); }); - test('removeForegroundWillDisplayListener removes listener', () { + test('removeForegroundWillDisplayListener removes listener', () async { var listenerCalled = false; void listener(OSNotificationWillDisplayEvent event) { listenerCalled = true; @@ -312,29 +303,53 @@ void main() { notifications.addForegroundWillDisplayListener(listener); notifications.removeForegroundWillDisplayListener(listener); + await notifications.handleMethod(MethodCall( + 'OneSignal#onWillDisplayNotification', notificationData)); expect(listenerCalled, false); }); - test('multiple will display listeners can be added', () { - void listener1(OSNotificationWillDisplayEvent event) {} - void listener2(OSNotificationWillDisplayEvent event) {} + test('multiple will display listeners can be added', () async { + var listener1Called = false; + var listener2Called = false; + void listener1(OSNotificationWillDisplayEvent event) { + listener1Called = true; + } + + void listener2(OSNotificationWillDisplayEvent event) { + listener2Called = true; + } notifications.addForegroundWillDisplayListener(listener1); notifications.addForegroundWillDisplayListener(listener2); - expect(true, true); + await notifications.handleMethod(MethodCall( + 'OneSignal#onWillDisplayNotification', notificationData)); + + expect(listener1Called, true); + expect(listener2Called, true); }); - test('can remove specific will display listener', () { - void listener1(OSNotificationWillDisplayEvent event) {} - void listener2(OSNotificationWillDisplayEvent event) {} + test('can remove specific will display listener', () async { + var listener1Called = false; + var listener2Called = false; + void listener1(OSNotificationWillDisplayEvent event) { + listener1Called = true; + } + + void listener2(OSNotificationWillDisplayEvent event) { + listener2Called = true; + } notifications.addForegroundWillDisplayListener(listener1); notifications.addForegroundWillDisplayListener(listener2); notifications.removeForegroundWillDisplayListener(listener1); - expect(true, true); + await notifications.handleMethod(MethodCall( + 'OneSignal#onWillDisplayNotification', notificationData)); + + expect(listener1Called, false); + expect(listener2Called, true); }); }); @@ -427,39 +442,6 @@ void main() { expect(callLog.length, 1); expect(callLog[0], false); }); - - test('click listener can be added and invoked multiple times', () { - void listener(OSNotificationClickEvent event) {} - - notifications.addClickListener(listener); - expect(true, true); - }); - - test('will display listener can be added and invoked multiple times', () { - void listener(OSNotificationWillDisplayEvent event) {} - - notifications.addForegroundWillDisplayListener(listener); - expect(true, true); - }); - - test('notification IDs are passed correctly through preventDefault', () { - final notificationIds = ['id-1', 'id-2', 'id-3']; - - for (final id in notificationIds) { - notifications.preventDefault(id); - expect(channelController.state.preventedNotificationId, id); - } - }); - - test('notification IDs are passed correctly through displayNotification', - () { - final notificationIds = ['id-1', 'id-2', 'id-3']; - - for (final id in notificationIds) { - notifications.displayNotification(id); - expect(channelController.state.displayedNotificationId, id); - } - }); }); }); } From 19d63654d2d63ef408cbc1955da7d8ab8b762d77 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 17 Nov 2025 17:52:19 -0800 Subject: [PATCH 3/7] add permission test --- test/permission_test.dart | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 test/permission_test.dart diff --git a/test/permission_test.dart b/test/permission_test.dart new file mode 100644 index 00000000..2d217ea3 --- /dev/null +++ b/test/permission_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:onesignal_flutter/src/permission.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('OSPermissionState', () { + group('constructor', () { + test('initializes with permission true when provided in json', () { + final json = {'permission': true}; + final state = OSPermissionState(json); + + expect(state.permission, true); + }); + + test('defaults to false when permission key is missing', () { + final json = {}; + final state = OSPermissionState(json); + + expect(state.permission, false); + }); + }); + + group('jsonRepresentation', () { + test('returns json string with permission true', () { + final json = {'permission': true}; + final state = OSPermissionState(json); + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"permission": true')); + }); + + test('returns properly formatted json string', () { + final json = {'permission': true}; + final state = OSPermissionState(json); + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"permission": true')); + }); + }); + + group('permission field', () { + test('can be modified after construction', () { + final json = {'permission': false}; + final state = OSPermissionState(json); + + expect(state.permission, false); + + state.permission = true; + expect(state.permission, true); + }); + + test('reflects changes in jsonRepresentation', () { + final json = {'permission': false}; + final state = OSPermissionState(json); + + state.permission = true; + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"permission": true')); + }); + }); + }); +} From e81416a7c11d9d66350d3e5e6b2a8138687eee84 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 17 Nov 2025 17:56:20 -0800 Subject: [PATCH 4/7] add pushsubscription test --- test/mock_channel.dart | 39 +++++ test/pushsubscription_test.dart | 248 ++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 test/pushsubscription_test.dart diff --git a/test/mock_channel.dart b/test/mock_channel.dart index 8f96033a..f54e30d7 100644 --- a/test/mock_channel.dart +++ b/test/mock_channel.dart @@ -20,6 +20,8 @@ class OneSignalMockChannelController { const MethodChannel('OneSignal#liveactivities'); final MethodChannel _notificationsChannel = const MethodChannel('OneSignal#notifications'); + final MethodChannel _pushSubscriptionChannel = + const MethodChannel('OneSignal#pushsubscription'); late OneSignalState state; @@ -38,12 +40,26 @@ class OneSignalMockChannelController { .setMockMethodCallHandler(_liveActivitiesChannel, _handleMethod); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_notificationsChannel, _handleMethod); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_pushSubscriptionChannel, _handleMethod); } void resetState() { state = OneSignalState(); } + // Helper method to simulate push subscription changes from native + void simulatePushSubscriptionChange(Map changeData) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _pushSubscriptionChannel.name, + _pushSubscriptionChannel.codec.encodeMethodCall( + MethodCall('OneSignal#onPushSubscriptionChange', changeData), + ), + (ByteData? data) {}, + ); + } + Future _handleMethod(MethodCall call) async { switch (call.method) { case "OneSignal#setAppId": @@ -209,6 +225,20 @@ class OneSignalMockChannelController { case "OneSignal#proceedWithWillDisplay": state.proceedWithWillDisplayCalled = true; break; + case "OneSignal#pushSubscriptionToken": + return state.pushSubscriptionToken; + case "OneSignal#pushSubscriptionId": + return state.pushSubscriptionId; + case "OneSignal#pushSubscriptionOptedIn": + return state.pushSubscriptionOptedIn; + case "OneSignal#optIn": + state.pushSubscriptionOptInCalled = true; + state.pushSubscriptionOptInCallCount++; + break; + case "OneSignal#optOut": + state.pushSubscriptionOptOutCalled = true; + state.pushSubscriptionOptOutCallCount++; + break; } } } @@ -283,6 +313,15 @@ class OneSignalState { int nativeClickListenerAddedCount = 0; bool? proceedWithWillDisplayCalled; + // push subscription + String? pushSubscriptionId; + String? pushSubscriptionToken; + bool? pushSubscriptionOptedIn; + bool pushSubscriptionOptInCalled = false; + bool pushSubscriptionOptOutCalled = false; + int pushSubscriptionOptInCallCount = 0; + int pushSubscriptionOptOutCallCount = 0; + /* All of the following functions parse the MethodCall parameters, and sets properties on the object itself diff --git a/test/pushsubscription_test.dart b/test/pushsubscription_test.dart new file mode 100644 index 00000000..b6ce2989 --- /dev/null +++ b/test/pushsubscription_test.dart @@ -0,0 +1,248 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:onesignal_flutter/src/pushsubscription.dart'; +import 'package:onesignal_flutter/src/subscription.dart'; + +import 'mock_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('OneSignalPushSubscription', () { + late OneSignalPushSubscription pushSubscription; + late OneSignalMockChannelController channelController; + + setUp(() { + channelController = OneSignalMockChannelController(); + channelController.resetState(); + pushSubscription = OneSignalPushSubscription(); + }); + + group('initial state', () { + test('id, token, and optedIn are null initially', () { + expect(pushSubscription.id, isNull); + expect(pushSubscription.token, isNull); + expect(pushSubscription.optedIn, isNull); + }); + }); + + group('lifecycleInit', () { + test('fetches and sets id, token, and optedIn', () async { + channelController.state.pushSubscriptionId = 'test-id-123'; + channelController.state.pushSubscriptionToken = 'test-token-456'; + channelController.state.pushSubscriptionOptedIn = true; + + await pushSubscription.lifecycleInit(); + + expect(pushSubscription.id, 'test-id-123'); + expect(pushSubscription.token, 'test-token-456'); + expect(pushSubscription.optedIn, true); + expect(channelController.state.lifecycleInitCalled, true); + }); + + test('handles null values from native', () async { + channelController.state.pushSubscriptionId = null; + channelController.state.pushSubscriptionToken = null; + channelController.state.pushSubscriptionOptedIn = false; + + await pushSubscription.lifecycleInit(); + + expect(pushSubscription.id, isNull); + expect(pushSubscription.token, isNull); + expect(pushSubscription.optedIn, false); + }); + + test('updates state when values change', () async { + channelController.state.pushSubscriptionId = 'id-1'; + channelController.state.pushSubscriptionToken = 'token-1'; + channelController.state.pushSubscriptionOptedIn = false; + + await pushSubscription.lifecycleInit(); + + expect(pushSubscription.id, 'id-1'); + expect(pushSubscription.token, 'token-1'); + expect(pushSubscription.optedIn, false); + + // Change mock state + channelController.state.pushSubscriptionId = 'id-2'; + channelController.state.pushSubscriptionToken = 'token-2'; + channelController.state.pushSubscriptionOptedIn = true; + + await pushSubscription.lifecycleInit(); + + expect(pushSubscription.id, 'id-2'); + expect(pushSubscription.token, 'token-2'); + expect(pushSubscription.optedIn, true); + }); + }); + + group('optIn', () { + test('calls native optIn method', () async { + await pushSubscription.optIn(); + + expect(channelController.state.pushSubscriptionOptInCalled, true); + }); + + test('can be called multiple times', () async { + await pushSubscription.optIn(); + await pushSubscription.optIn(); + await pushSubscription.optIn(); + + expect(channelController.state.pushSubscriptionOptInCallCount, 3); + }); + }); + + group('optOut', () { + test('calls native optOut method', () async { + await pushSubscription.optOut(); + + expect(channelController.state.pushSubscriptionOptOutCalled, true); + }); + + test('can be called multiple times', () async { + await pushSubscription.optOut(); + await pushSubscription.optOut(); + + expect(channelController.state.pushSubscriptionOptOutCallCount, 2); + }); + }); + + group('observers', () { + test('can add observer', () { + bool observerCalled = false; + + pushSubscription.addObserver((stateChanges) { + observerCalled = true; + }); + + // Trigger a change via mock channel + final changeData = { + 'current': {'id': 'new-id', 'token': 'new-token', 'optedIn': true}, + 'previous': {'id': 'old-id', 'token': 'old-token', 'optedIn': false}, + }; + + channelController.simulatePushSubscriptionChange(changeData); + + expect(observerCalled, true); + }); + + test('can add multiple observers', () { + int observer1CallCount = 0; + int observer2CallCount = 0; + + pushSubscription.addObserver((stateChanges) { + observer1CallCount++; + }); + + pushSubscription.addObserver((stateChanges) { + observer2CallCount++; + }); + + final changeData = { + 'current': {'id': 'id', 'token': 'token', 'optedIn': true}, + 'previous': {'id': 'id', 'token': 'token', 'optedIn': false}, + }; + + channelController.simulatePushSubscriptionChange(changeData); + + expect(observer1CallCount, 1); + expect(observer2CallCount, 1); + }); + + test('can remove observer', () { + int callCount = 0; + + void observer(OSPushSubscriptionChangedState stateChanges) { + callCount++; + } + + pushSubscription.addObserver(observer); + pushSubscription.removeObserver(observer); + + final changeData = { + 'current': {'id': 'id', 'token': 'token', 'optedIn': true}, + 'previous': {'id': 'id', 'token': 'token', 'optedIn': false}, + }; + + channelController.simulatePushSubscriptionChange(changeData); + + expect(callCount, 0); + }); + + test('observer receives correct state changes', () { + OSPushSubscriptionChangedState? receivedState; + + pushSubscription.addObserver((stateChanges) { + receivedState = stateChanges; + }); + + final changeData = { + 'current': {'id': 'new-id', 'token': 'new-token', 'optedIn': true}, + 'previous': {'id': 'old-id', 'token': 'old-token', 'optedIn': false}, + }; + + channelController.simulatePushSubscriptionChange(changeData); + + expect(receivedState, isNotNull); + expect(receivedState!.current.id, 'new-id'); + expect(receivedState!.current.token, 'new-token'); + expect(receivedState!.current.optedIn, true); + expect(receivedState!.previous.id, 'old-id'); + expect(receivedState!.previous.token, 'old-token'); + expect(receivedState!.previous.optedIn, false); + }); + }); + + group('onPushSubscriptionChange', () { + test('updates internal state when subscription changes', () async { + channelController.state.pushSubscriptionId = 'initial-id'; + channelController.state.pushSubscriptionToken = 'initial-token'; + channelController.state.pushSubscriptionOptedIn = false; + + await pushSubscription.lifecycleInit(); + + expect(pushSubscription.id, 'initial-id'); + expect(pushSubscription.token, 'initial-token'); + expect(pushSubscription.optedIn, false); + + // Simulate a subscription change + final changeData = { + 'current': { + 'id': 'updated-id', + 'token': 'updated-token', + 'optedIn': true + }, + 'previous': { + 'id': 'initial-id', + 'token': 'initial-token', + 'optedIn': false + }, + }; + + channelController.simulatePushSubscriptionChange(changeData); + + expect(pushSubscription.id, 'updated-id'); + expect(pushSubscription.token, 'updated-token'); + expect(pushSubscription.optedIn, true); + }); + + test('handles null values in state changes', () async { + channelController.state.pushSubscriptionId = 'id'; + channelController.state.pushSubscriptionToken = 'token'; + channelController.state.pushSubscriptionOptedIn = true; + + await pushSubscription.lifecycleInit(); + + final changeData = { + 'current': {'id': null, 'token': null, 'optedIn': false}, + 'previous': {'id': 'id', 'token': 'token', 'optedIn': true}, + }; + + channelController.simulatePushSubscriptionChange(changeData); + + expect(pushSubscription.id, isNull); + expect(pushSubscription.token, isNull); + expect(pushSubscription.optedIn, false); + }); + }); + }); +} From e36a955ae1c1b90bf05574bd195338d061f4dc95 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 18 Nov 2025 10:05:28 -0800 Subject: [PATCH 5/7] add session test --- test/mock_channel.dart | 27 ++++++++++ test/session_test.dart | 113 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 test/session_test.dart diff --git a/test/mock_channel.dart b/test/mock_channel.dart index f54e30d7..2eaabe1e 100644 --- a/test/mock_channel.dart +++ b/test/mock_channel.dart @@ -22,6 +22,8 @@ class OneSignalMockChannelController { const MethodChannel('OneSignal#notifications'); final MethodChannel _pushSubscriptionChannel = const MethodChannel('OneSignal#pushsubscription'); + final MethodChannel _sessionChannel = + const MethodChannel('OneSignal#session'); late OneSignalState state; @@ -42,6 +44,8 @@ class OneSignalMockChannelController { .setMockMethodCallHandler(_notificationsChannel, _handleMethod); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_pushSubscriptionChannel, _handleMethod); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_sessionChannel, _handleMethod); } void resetState() { @@ -239,6 +243,20 @@ class OneSignalMockChannelController { state.pushSubscriptionOptOutCalled = true; state.pushSubscriptionOptOutCallCount++; break; + case "OneSignal#addOutcome": + state.addedOutcome = call.arguments as String; + state.addOutcomeCallCount++; + break; + case "OneSignal#addUniqueOutcome": + state.addedUniqueOutcome = call.arguments as String; + state.addUniqueOutcomeCallCount++; + break; + case "OneSignal#addOutcomeWithValue": + final args = call.arguments as Map; + state.addedOutcomeWithValueName = args['outcome_name'] as String; + state.addedOutcomeWithValueValue = args['outcome_value'] as double; + state.addOutcomeWithValueCallCount++; + break; } } } @@ -322,6 +340,15 @@ class OneSignalState { int pushSubscriptionOptInCallCount = 0; int pushSubscriptionOptOutCallCount = 0; + // session outcomes + String? addedOutcome; + int addOutcomeCallCount = 0; + String? addedUniqueOutcome; + int addUniqueOutcomeCallCount = 0; + String? addedOutcomeWithValueName; + double? addedOutcomeWithValueValue; + int addOutcomeWithValueCallCount = 0; + /* All of the following functions parse the MethodCall parameters, and sets properties on the object itself diff --git a/test/session_test.dart b/test/session_test.dart new file mode 100644 index 00000000..4ef608cc --- /dev/null +++ b/test/session_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:onesignal_flutter/src/session.dart'; + +import 'mock_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('OneSignalSession', () { + late OneSignalSession session; + late OneSignalMockChannelController channelController; + + setUp(() { + channelController = OneSignalMockChannelController(); + channelController.resetState(); + session = OneSignalSession(); + }); + + group('addOutcome', () { + test('invokes OneSignal#addOutcome with outcome name', () async { + const outcomeName = 'test_outcome'; + + await session.addOutcome(outcomeName); + + expect(channelController.state.addedOutcome, outcomeName); + }); + + test('can be called multiple times', () async { + await session.addOutcome('outcome1'); + await session.addOutcome('outcome2'); + await session.addOutcome('outcome3'); + + expect(channelController.state.addedOutcome, 'outcome3'); + expect(channelController.state.addOutcomeCallCount, 3); + }); + + test('handles empty string outcome name', () async { + await session.addOutcome(''); + + expect(channelController.state.addedOutcome, ''); + }); + }); + + group('addUniqueOutcome', () { + test('invokes OneSignal#addUniqueOutcome with outcome name', () async { + const outcomeName = 'unique_outcome'; + + await session.addUniqueOutcome(outcomeName); + + expect(channelController.state.addedUniqueOutcome, outcomeName); + }); + + test('can be called multiple times', () async { + await session.addUniqueOutcome('unique1'); + await session.addUniqueOutcome('unique2'); + + expect(channelController.state.addedUniqueOutcome, 'unique2'); + expect(channelController.state.addUniqueOutcomeCallCount, 2); + }); + + test('handles empty string outcome name', () async { + await session.addUniqueOutcome(''); + + expect(channelController.state.addedUniqueOutcome, ''); + }); + }); + + group('addOutcomeWithValue', () { + test('invokes OneSignal#addOutcomeWithValue with name and value', + () async { + const outcomeName = 'valued_outcome'; + const outcomeValue = 42.5; + + await session.addOutcomeWithValue(outcomeName, outcomeValue); + + expect(channelController.state.addedOutcomeWithValueName, outcomeName); + expect( + channelController.state.addedOutcomeWithValueValue, outcomeValue); + }); + + test('handles negative value', () async { + const outcomeName = 'negative_outcome'; + const outcomeValue = -10.5; + + await session.addOutcomeWithValue(outcomeName, outcomeValue); + + expect(channelController.state.addedOutcomeWithValueName, outcomeName); + expect( + channelController.state.addedOutcomeWithValueValue, outcomeValue); + }); + + test('can be called multiple times with different values', () async { + await session.addOutcomeWithValue('outcome1', 10.0); + await session.addOutcomeWithValue('outcome2', 20.5); + await session.addOutcomeWithValue('outcome3', 30.75); + + expect(channelController.state.addedOutcomeWithValueName, 'outcome3'); + expect(channelController.state.addedOutcomeWithValueValue, 30.75); + expect(channelController.state.addOutcomeWithValueCallCount, 3); + }); + + test('handles empty string outcome name with value', () async { + const outcomeValue = 15.5; + + await session.addOutcomeWithValue('', outcomeValue); + + expect(channelController.state.addedOutcomeWithValueName, ''); + expect( + channelController.state.addedOutcomeWithValueValue, outcomeValue); + }); + }); + }); +} From b6fe40aff8448861948ac9d87691f3e29db5e37d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 18 Nov 2025 10:14:20 -0800 Subject: [PATCH 6/7] add subscription test --- test/subscription_test.dart | 284 ++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 test/subscription_test.dart diff --git a/test/subscription_test.dart b/test/subscription_test.dart new file mode 100644 index 00000000..ca90dee4 --- /dev/null +++ b/test/subscription_test.dart @@ -0,0 +1,284 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:onesignal_flutter/src/subscription.dart'; + +final pushSubState = { + 'id': 'test-id-123', + 'token': 'test-token-456', + 'optedIn': true, +}; + +final pushChangeState = { + 'current': { + 'id': 'current-id', + 'token': 'current-token', + 'optedIn': true, + }, + 'previous': { + 'id': 'previous-id', + 'token': 'previous-token', + 'optedIn': false, + }, +}; + +final pushNullChangeState = { + 'current': {'id': null, 'token': null, 'optedIn': false}, + 'previous': { + 'id': 'previous-id', + 'token': 'previous-token', + 'optedIn': true, + }, +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('OSPushSubscriptionState', () { + group('constructor', () { + test('initializes with all fields when provided', () { + final state = OSPushSubscriptionState(pushSubState); + + expect(state.id, 'test-id-123'); + expect(state.token, 'test-token-456'); + expect(state.optedIn, true); + }); + + test('initializes with optedIn false', () { + final state = OSPushSubscriptionState({ + ...pushSubState, + 'optedIn': false, + }); + + expect(state.id, 'test-id-123'); + expect(state.token, 'test-token-456'); + expect(state.optedIn, false); + }); + + test('handles null/missing id', () { + // with null id + final stateWithNull = OSPushSubscriptionState({ + ...pushSubState, + 'id': null, + }); + expect(stateWithNull.id, isNull); + expect(stateWithNull.token, 'test-token-456'); + expect(stateWithNull.optedIn, true); + + // with missing id key + final stateWithMissing = OSPushSubscriptionState({ + 'token': 'test-token', + 'optedIn': true, + }); + expect(stateWithMissing.id, isNull); + expect(stateWithMissing.token, 'test-token'); + expect(stateWithMissing.optedIn, true); + }); + + test('handles null/missing token', () { + // with null token + final stateWithNull = OSPushSubscriptionState({ + ...pushSubState, + 'token': null, + }); + expect(stateWithNull.id, 'test-id-123'); + expect(stateWithNull.token, isNull); + expect(stateWithNull.optedIn, true); + + // with missing token key + final stateWithMissing = OSPushSubscriptionState({ + 'id': 'test-id', + 'optedIn': false, + }); + expect(stateWithMissing.id, 'test-id'); + expect(stateWithMissing.token, isNull); + expect(stateWithMissing.optedIn, false); + }); + + test('handles all null values', () { + final state = OSPushSubscriptionState({ + 'id': null, + 'token': null, + 'optedIn': false, + }); + + expect(state.id, isNull); + expect(state.token, isNull); + expect(state.optedIn, false); + }); + }); + + group('jsonRepresentation', () { + test('returns json string with all fields', () { + final state = OSPushSubscriptionState(pushSubState); + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"id": "test-id-123"')); + expect(jsonString, contains('"token": "test-token-456"')); + expect(jsonString, contains('"optedIn": true')); + }); + + test('returns json string with null fields', () { + final state = OSPushSubscriptionState({ + ...pushSubState, + 'id': null, + 'token': null, + 'optedIn': false, + }); + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"id": null')); + expect(jsonString, contains('"token": null')); + expect(jsonString, contains('"optedIn": false')); + }); + }); + + group('field modification', () { + test('fields can be modified after construction', () { + final state = OSPushSubscriptionState(pushSubState); + + // Verify initial values + expect(state.id, 'test-id-123'); + expect(state.token, 'test-token-456'); + expect(state.optedIn, true); + + // Modify id + state.id = 'new-id'; + expect(state.id, 'new-id'); + + // Modify token + state.token = 'new-token'; + expect(state.token, 'new-token'); + + // Modify optedIn + state.optedIn = false; + expect(state.optedIn, false); + }); + + test('modifications reflect in jsonRepresentation', () { + final state = OSPushSubscriptionState(pushSubState); + state.id = 'modified-id'; + state.token = 'modified-token'; + state.optedIn = true; + + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"id": "modified-id"')); + expect(jsonString, contains('"token": "modified-token"')); + expect(jsonString, contains('"optedIn": true')); + }); + }); + }); + + group('OSPushSubscriptionChangedState', () { + group('constructor', () { + test('initializes with current and previous states', () { + final changedState = OSPushSubscriptionChangedState(pushChangeState); + + expect(changedState.current.id, 'current-id'); + expect(changedState.current.token, 'current-token'); + expect(changedState.current.optedIn, true); + + expect(changedState.previous.id, 'previous-id'); + expect(changedState.previous.token, 'previous-token'); + expect(changedState.previous.optedIn, false); + }); + + test('handles null values in current state', () { + final changedState = + OSPushSubscriptionChangedState(pushNullChangeState); + + expect(changedState.current.id, isNull); + expect(changedState.current.token, isNull); + expect(changedState.current.optedIn, false); + + expect(changedState.previous.id, 'previous-id'); + expect(changedState.previous.token, 'previous-token'); + expect(changedState.previous.optedIn, true); + }); + + test('handles null values in previous state', () { + final changedState = OSPushSubscriptionChangedState({ + 'current': { + 'id': 'current-id', + 'token': 'current-token', + 'optedIn': true, + }, + 'previous': {'id': null, 'token': null, 'optedIn': false}, + }); + + expect(changedState.current.id, 'current-id'); + expect(changedState.current.token, 'current-token'); + expect(changedState.current.optedIn, true); + + expect(changedState.previous.id, isNull); + expect(changedState.previous.token, isNull); + expect(changedState.previous.optedIn, false); + }); + }); + + group('jsonRepresentation', () { + test('returns json string with current and previous states', () { + final changedState = OSPushSubscriptionChangedState(pushChangeState); + final jsonString = changedState.jsonRepresentation(); + + expect(jsonString, contains('"current":')); + expect(jsonString, contains('"previous":')); + expect(jsonString, contains('"id": "current-id"')); + expect(jsonString, contains('"token": "current-token"')); + expect(jsonString, contains('"optedIn": true')); + expect(jsonString, contains('"id": "previous-id"')); + expect(jsonString, contains('"token": "previous-token"')); + expect(jsonString, contains('"optedIn": false')); + }); + + test('handles null values in json representation', () { + final changedState = + OSPushSubscriptionChangedState(pushNullChangeState); + final jsonString = changedState.jsonRepresentation(); + + expect(jsonString, contains('"current"')); + expect(jsonString, contains('"previous"')); + expect(jsonString, contains('"id": null')); + expect(jsonString, contains('"token": null')); + expect(jsonString, contains('"optedIn": false')); + expect(jsonString, contains('"id": "previous-id"')); + expect(jsonString, contains('"token": "previous-token"')); + expect(jsonString, contains('"optedIn": true')); + }); + }); + + group('state modification', () { + test('current state can be modified', () { + final changedState = OSPushSubscriptionChangedState(pushChangeState); + changedState.current.id = 'modified-id'; + changedState.current.token = 'modified-token'; + changedState.current.optedIn = false; + + expect(changedState.current.id, 'modified-id'); + expect(changedState.current.token, 'modified-token'); + expect(changedState.current.optedIn, false); + }); + + test('previous state can be modified', () { + final changedState = OSPushSubscriptionChangedState(pushChangeState); + changedState.previous.id = 'modified-id'; + changedState.previous.token = 'modified-token'; + changedState.previous.optedIn = true; + + expect(changedState.previous.id, 'modified-id'); + expect(changedState.previous.token, 'modified-token'); + expect(changedState.previous.optedIn, true); + }); + + test('modifications to states reflect in jsonRepresentation', () { + final changedState = OSPushSubscriptionChangedState(pushChangeState); + changedState.current.id = 'new-current-id'; + changedState.previous.id = 'new-previous-id'; + + final jsonString = changedState.jsonRepresentation(); + + expect(jsonString, contains('"id": "new-current-id"')); + expect(jsonString, contains('"id": "new-previous-id"')); + }); + }); + }); +} From 258041efc8142fc46c1bb3790ea97c92c0877992 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 18 Nov 2025 11:00:31 -0800 Subject: [PATCH 7/7] add user test --- test/mock_channel.dart | 65 +++++++ test/user_test.dart | 378 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 test/user_test.dart diff --git a/test/mock_channel.dart b/test/mock_channel.dart index 2eaabe1e..8fdb6f64 100644 --- a/test/mock_channel.dart +++ b/test/mock_channel.dart @@ -24,6 +24,7 @@ class OneSignalMockChannelController { const MethodChannel('OneSignal#pushsubscription'); final MethodChannel _sessionChannel = const MethodChannel('OneSignal#session'); + final MethodChannel _userChannel = const MethodChannel('OneSignal#user'); late OneSignalState state; @@ -46,6 +47,8 @@ class OneSignalMockChannelController { .setMockMethodCallHandler(_pushSubscriptionChannel, _handleMethod); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_sessionChannel, _handleMethod); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_userChannel, _handleMethod); } void resetState() { @@ -64,6 +67,18 @@ class OneSignalMockChannelController { ); } + // Helper method to simulate user state changes from native + void simulateUserStateChange(Map changeData) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _userChannel.name, + _userChannel.codec.encodeMethodCall( + MethodCall('OneSignal#onUserStateChange', changeData), + ), + (ByteData? data) {}, + ); + } + Future _handleMethod(MethodCall call) async { switch (call.method) { case "OneSignal#setAppId": @@ -257,6 +272,46 @@ class OneSignalMockChannelController { state.addedOutcomeWithValueValue = args['outcome_value'] as double; state.addOutcomeWithValueCallCount++; break; + case "OneSignal#setLanguage": + state.language = + (call.arguments as Map)['language'] as String?; + break; + case "OneSignal#addAliases": + state.aliases = call.arguments as Map?; + break; + case "OneSignal#removeAliases": + state.removedAliases = call.arguments as List?; + break; + case "OneSignal#addTags": + state.tags = call.arguments as Map?; + break; + case "OneSignal#removeTags": + state.deleteTags = call.arguments as List?; + break; + case "OneSignal#getTags": + return state.tags ?? {}; + case "OneSignal#addEmail": + state.addedEmail = call.arguments as String?; + break; + case "OneSignal#removeEmail": + state.removedEmail = call.arguments as String?; + break; + case "OneSignal#addSms": + state.addedSms = call.arguments as String?; + break; + case "OneSignal#removeSms": + state.removedSms = call.arguments as String?; + break; + case "OneSignal#getExternalId": + return state.externalId; + case "OneSignal#getOnesignalId": + return state.onesignalId; + case "OneSignal#lifecycleInit": + // Could be from user, inappmessages, or pushsubscription + // We'll track both + state.lifecycleInitCalled = true; + state.userLifecycleInitCalled = true; + break; } } } @@ -349,6 +404,16 @@ class OneSignalState { double? addedOutcomeWithValueValue; int addOutcomeWithValueCallCount = 0; + // user + String? onesignalId; + Map? aliases; + List? removedAliases; + String? addedEmail; + String? removedEmail; + String? addedSms; + String? removedSms; + bool? userLifecycleInitCalled; + /* All of the following functions parse the MethodCall parameters, and sets properties on the object itself diff --git a/test/user_test.dart b/test/user_test.dart new file mode 100644 index 00000000..b4c95bff --- /dev/null +++ b/test/user_test.dart @@ -0,0 +1,378 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:onesignal_flutter/src/user.dart'; + +import 'mock_channel.dart'; + +final userState = { + 'onesignalId': 'test-onesignal-id', + 'externalId': 'test-external-id', +}; + +final email = 'test@example.com'; +final sms = '+1234567890'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late OneSignalMockChannelController controller; + late OneSignalUser user; + + setUp(() { + controller = OneSignalMockChannelController(); + controller.resetState(); + user = OneSignalUser(); + }); + + group('OSUserState', () { + group('constructor', () { + test('initializes with all fields when provided', () { + final state = OSUserState(userState); + + expect(state.onesignalId, 'test-onesignal-id'); + expect(state.externalId, 'test-external-id'); + }); + + test('handles null/missing onesignalId', () { + // with null onesignalId + final stateWithNull = OSUserState({ + ...userState, + 'onesignalId': null, + }); + expect(stateWithNull.onesignalId, isNull); + expect(stateWithNull.externalId, 'test-external-id'); + + // with missing onesignalId key + final stateWithMissing = OSUserState({ + 'externalId': 'test-external-id', + }); + expect(stateWithMissing.onesignalId, isNull); + expect(stateWithMissing.externalId, 'test-external-id'); + }); + + test('handles null/missing externalId', () { + // with null externalId + final stateWithNull = OSUserState({ + ...userState, + 'externalId': null, + }); + expect(stateWithNull.onesignalId, 'test-onesignal-id'); + expect(stateWithNull.externalId, isNull); + + // with missing externalId key + final stateWithMissing = OSUserState({ + 'onesignalId': 'test-onesignal-id', + }); + expect(stateWithMissing.onesignalId, 'test-onesignal-id'); + expect(stateWithMissing.externalId, isNull); + }); + + test('handles all null values', () { + final state = OSUserState({ + 'onesignalId': null, + 'externalId': null, + }); + + expect(state.onesignalId, isNull); + expect(state.externalId, isNull); + }); + }); + + group('jsonRepresentation', () { + test('returns json string with all fields', () { + final state = OSUserState(userState); + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"onesignalId": "test-onesignal-id"')); + expect(jsonString, contains('"externalId": "test-external-id"')); + }); + + test('returns json string with null fields', () { + final state = OSUserState({ + 'onesignalId': null, + 'externalId': null, + }); + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"onesignalId": null')); + expect(jsonString, contains('"externalId": null')); + }); + }); + + group('field modification', () { + test('fields can be modified after construction', () { + final state = OSUserState(userState); + + // Verify initial values + expect(state.onesignalId, 'test-onesignal-id'); + expect(state.externalId, 'test-external-id'); + + // Modify onesignalId + state.onesignalId = 'new-onesignal-id'; + expect(state.onesignalId, 'new-onesignal-id'); + + // Modify externalId + state.externalId = 'new-external-id'; + expect(state.externalId, 'new-external-id'); + }); + + test('modifications reflect in jsonRepresentation', () { + final state = OSUserState(userState); + state.onesignalId = 'modified-onesignal-id'; + state.externalId = 'modified-external-id'; + + final jsonString = state.jsonRepresentation(); + + expect(jsonString, contains('"onesignalId": "modified-onesignal-id"')); + expect(jsonString, contains('"externalId": "modified-external-id"')); + }); + }); + }); + + group('OSUserChangedState', () { + test('initializes with current state', () { + final changedState = OSUserChangedState({ + 'current': userState, + }); + + expect(changedState.current.onesignalId, 'test-onesignal-id'); + expect(changedState.current.externalId, 'test-external-id'); + }); + + test('handles null values in current state', () { + final changedState = OSUserChangedState({ + 'current': { + 'onesignalId': null, + 'externalId': null, + }, + }); + + expect(changedState.current.onesignalId, isNull); + expect(changedState.current.externalId, isNull); + }); + + test('jsonRepresentation returns correct format', () { + final changedState = OSUserChangedState({ + 'current': userState, + }); + final jsonString = changedState.jsonRepresentation(); + + expect(jsonString, contains('"current":')); + expect(jsonString, contains('"onesignalId": "test-onesignal-id"')); + expect(jsonString, contains('"externalId": "test-external-id"')); + }); + }); + + group('OneSignalUser', () { + test('setLanguage invokes native method with language', () async { + await user.setLanguage('es'); + + expect(controller.state.language, 'es'); + }); + + test('addAlias invokes native method with alias', () async { + await user.addAlias('customId', '12345'); + + expect(controller.state.aliases, {'customId': '12345'}); + }); + + test('addAliases invokes native method with multiple aliases', () async { + await user.addAliases({ + 'customId': '12345', + 'userId': 'abc', + }); + + expect(controller.state.aliases, { + 'customId': '12345', + 'userId': 'abc', + }); + }); + + test('removeAlias invokes native method with alias label', () async { + await user.removeAlias('customId'); + + expect(controller.state.removedAliases, ['customId']); + }); + + test('removeAliases invokes native method with multiple labels', () async { + await user.removeAliases(['customId', 'userId']); + + expect(controller.state.removedAliases, ['customId', 'userId']); + }); + + test('addTagWithKey invokes native method with tag', () async { + await user.addTagWithKey('level', 10); + + expect(controller.state.tags, {'level': '10'}); + }); + + test('addTags invokes native method with multiple tags', () async { + await user.addTags({ + 'level': 10, + 'score': 500, + 'name': 'Player1', + }); + + expect(controller.state.tags, { + 'level': '10', + 'score': '500', + 'name': 'Player1', + }); + }); + + test('removeTag invokes native method with tag key', () async { + await user.removeTag('level'); + + expect(controller.state.deleteTags, ['level']); + }); + + test('removeTags invokes native method with multiple keys', () async { + await user.removeTags(['level', 'score']); + + expect(controller.state.deleteTags, ['level', 'score']); + }); + + test('getTags returns tags from native', () async { + controller.state.tags = { + 'level': '10', + 'score': '500', + }; + + final tags = await user.getTags(); + + expect(tags, { + 'level': '10', + 'score': '500', + }); + }); + + test('addEmail invokes native method with email', () async { + await user.addEmail(email); + + expect(controller.state.addedEmail, email); + }); + + test('removeEmail invokes native method with email', () async { + await user.removeEmail(email); + + expect(controller.state.removedEmail, email); + }); + + test('addSms invokes native method with sms number', () async { + await user.addSms(sms); + + expect(controller.state.addedSms, sms); + }); + + test('removeSms invokes native method with sms number', () async { + await user.removeSms(sms); + + expect(controller.state.removedSms, sms); + }); + + test('getExternalId returns external id from native', () async { + controller.state.externalId = 'external-123'; + + final externalId = await user.getExternalId(); + + expect(externalId, 'external-123'); + }); + + test('getOnesignalId returns onesignal id from native', () async { + controller.state.onesignalId = 'onesignal-456'; + + final onesignalId = await user.getOnesignalId(); + + expect(onesignalId, 'onesignal-456'); + }); + + test('lifecycleInit invokes native method', () async { + await user.lifecycleInit(); + + expect(controller.state.lifecycleInitCalled, true); + }); + + group('observers', () { + test('can add observer', () { + bool observerCalled = false; + OSUserChangedState? receivedState; + + user.addObserver((stateChanges) { + observerCalled = true; + receivedState = stateChanges; + }); + + controller.simulateUserStateChange({ + 'current': {'onesignalId': 'new-id', 'externalId': 'new-external'}, + }); + + expect(observerCalled, true); + expect(receivedState!.current.onesignalId, 'new-id'); + expect(receivedState!.current.externalId, 'new-external'); + }); + + test('can add multiple observers', () { + int callCount = 0; + user.addObserver((stateChanges) => callCount++); + user.addObserver((stateChanges) => callCount++); + + controller.simulateUserStateChange({ + 'current': {'onesignalId': 'id', 'externalId': 'ext'}, + }); + + expect(callCount, 2); + }); + + test('can remove observer', () { + bool observerCalled = false; + void observer(OSUserChangedState stateChanges) { + observerCalled = true; + } + + user.addObserver(observer); + user.removeObserver(observer); + + controller.simulateUserStateChange({ + 'current': {'onesignalId': 'id', 'externalId': 'ext'}, + }); + + expect(observerCalled, false); + }); + }); + + group('onUserStateChange', () { + test('updates state when user state changes', () async { + OSUserChangedState? receivedState; + user.addObserver((stateChanges) { + receivedState = stateChanges; + }); + + await user.lifecycleInit(); + + controller.simulateUserStateChange({ + 'current': { + 'onesignalId': 'changed-id', + 'externalId': 'changed-external', + }, + }); + + expect(receivedState!.current.onesignalId, 'changed-id'); + expect(receivedState!.current.externalId, 'changed-external'); + }); + + test('notifies all observers', () async { + int callCount = 0; + user.addObserver((stateChanges) => callCount++); + user.addObserver((stateChanges) => callCount++); + user.addObserver((stateChanges) => callCount++); + + await user.lifecycleInit(); + + controller.simulateUserStateChange({ + 'current': userState, + }); + + expect(callCount, 3); + }); + }); + }); +}