브라우저 전경 알림 표시와 click routing의 첫 구현을 남기고, 리뷰에서 확인된 실제 web click bridge 보완 계획을 함께 기록한다.
653 lines
20 KiB
Dart
653 lines
20 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nexo_messaging/nexo_messaging.dart';
|
|
import 'package:nexo_messaging/src/web/browser_notification_interop.dart';
|
|
import 'package:nexo_messaging/src/web/foreground_message_mapper.dart';
|
|
|
|
/// ignore: one_member_abstracts, avoid_implementing_value_types, public_member_api_docs
|
|
class FakeBrowserNotificationInterop implements BrowserNotificationInterop {
|
|
FakeBrowserNotificationInterop({
|
|
this.isSupportedParam = true,
|
|
this.permissionParam = 'granted',
|
|
}) : _isSupported = isSupportedParam,
|
|
_permission = permissionParam;
|
|
|
|
final bool _isSupported;
|
|
final String _permission;
|
|
final bool isSupportedParam;
|
|
final String permissionParam;
|
|
|
|
bool showCalled = false;
|
|
Map<String, Object?>? lastShowOptions;
|
|
|
|
@override
|
|
bool get isSupported => _isSupported;
|
|
|
|
@override
|
|
String get permission => _permission;
|
|
|
|
@override
|
|
bool show(Map<String, Object?> options) {
|
|
if (!isSupported) return false;
|
|
showCalled = true;
|
|
lastShowOptions = Map<String, Object?>.from(options);
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<String> requestPermission() async => permission;
|
|
|
|
void Function(Map<String, Object?>)? _onClick;
|
|
|
|
@override
|
|
void setClickHandler(void Function(Map<String, Object?>) onClick) {
|
|
_onClick = onClick;
|
|
}
|
|
|
|
void triggerClick(Map<String, Object?> data) {
|
|
_onClick?.call(data);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('NotificationOpenedEvent.fromMap', () {
|
|
test('parses server_url, channel_id, root_id, is_crt_enabled', () {
|
|
final event = NotificationOpenedEvent.fromMap({
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-123',
|
|
'root_id': 'root-456',
|
|
'is_crt_enabled': 'true',
|
|
});
|
|
|
|
expect(event.serverUrl, 'https://nexo.example.com');
|
|
expect(event.channelId, 'channel-123');
|
|
expect(event.rootId, 'root-456');
|
|
expect(event.isCRTEnabled, isTrue);
|
|
});
|
|
|
|
test('defaults isCRTEnabled to false when flag is absent or non-true', () {
|
|
final missing = NotificationOpenedEvent.fromMap({
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-123',
|
|
});
|
|
expect(missing.isCRTEnabled, isFalse);
|
|
|
|
final falsy = NotificationOpenedEvent.fromMap({
|
|
'is_crt_enabled': 'false',
|
|
});
|
|
expect(falsy.isCRTEnabled, isFalse);
|
|
});
|
|
|
|
test('accepts boolean and numeric-string CRT truthy flags', () {
|
|
final boolean = NotificationOpenedEvent.fromMap({'is_crt_enabled': true});
|
|
final numericString = NotificationOpenedEvent.fromMap({
|
|
'is_crt_enabled': '1',
|
|
});
|
|
|
|
expect(boolean.isCRTEnabled, isTrue);
|
|
expect(numericString.isCRTEnabled, isTrue);
|
|
});
|
|
});
|
|
|
|
group('NexoMessagingPlugin.handleNativeEvent', () {
|
|
late NexoMessagingPlugin plugin;
|
|
final actionChannel = MethodChannel(NexoMessagingPlugin.actionChannelName);
|
|
|
|
setUp(() {
|
|
plugin = NexoMessagingPlugin.instance;
|
|
plugin.onNavigateToChannel = null;
|
|
plugin.onNavigateToThread = null;
|
|
plugin.onDeviceTokenReady = null;
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(actionChannel, null);
|
|
});
|
|
|
|
test('opened event emits NotificationOpenedEvent on stream', () async {
|
|
final future = plugin.onNotificationOpened.first;
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.opened,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-123',
|
|
});
|
|
|
|
final event = await future;
|
|
expect(event.serverUrl, 'https://nexo.example.com');
|
|
expect(event.channelId, 'channel-123');
|
|
});
|
|
|
|
test(
|
|
'message event with userInteraction routes through opened handler',
|
|
() async {
|
|
String? navServer;
|
|
String? navChannel;
|
|
plugin.onNavigateToChannel = (s, c) {
|
|
navServer = s;
|
|
navChannel = c;
|
|
};
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.message,
|
|
'userInteraction': true,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-xyz',
|
|
});
|
|
|
|
expect(navServer, 'https://nexo.example.com');
|
|
expect(navChannel, 'channel-xyz');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'message event without userInteraction does not trigger navigation',
|
|
() async {
|
|
var channelCalled = false;
|
|
var threadCalled = false;
|
|
plugin.onNavigateToChannel = (_, _) => channelCalled = true;
|
|
plugin.onNavigateToThread = (_, _) => threadCalled = true;
|
|
|
|
final openedEvents = <NotificationOpenedEvent>[];
|
|
final sub = plugin.onNotificationOpened.listen(openedEvents.add);
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.message,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-xyz',
|
|
});
|
|
|
|
// Allow any pending microtasks to settle.
|
|
await Future<void>.delayed(Duration.zero);
|
|
await sub.cancel();
|
|
|
|
expect(channelCalled, isFalse);
|
|
expect(threadCalled, isFalse);
|
|
expect(openedEvents, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('opened event with CRT and rootId routes to thread callback', () {
|
|
String? navServer;
|
|
String? navRoot;
|
|
plugin.onNavigateToThread = (s, r) {
|
|
navServer = s;
|
|
navRoot = r;
|
|
};
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.opened,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-123',
|
|
'root_id': 'root-789',
|
|
'is_crt_enabled': 'true',
|
|
});
|
|
|
|
expect(navServer, 'https://nexo.example.com');
|
|
expect(navRoot, 'root-789');
|
|
});
|
|
|
|
test(
|
|
'opened event with boolean CRT and rootId routes to thread callback',
|
|
() {
|
|
String? navServer;
|
|
String? navRoot;
|
|
plugin.onNavigateToThread = (s, r) {
|
|
navServer = s;
|
|
navRoot = r;
|
|
};
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.opened,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-123',
|
|
'root_id': 'root-789',
|
|
'is_crt_enabled': true,
|
|
});
|
|
|
|
expect(navServer, 'https://nexo.example.com');
|
|
expect(navRoot, 'root-789');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'opened event with CRT but empty rootId falls back to channel callback',
|
|
() {
|
|
String? navServer;
|
|
String? navChannel;
|
|
var threadCalled = false;
|
|
plugin.onNavigateToChannel = (s, c) {
|
|
navServer = s;
|
|
navChannel = c;
|
|
};
|
|
plugin.onNavigateToThread = (_, _) => threadCalled = true;
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.opened,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-123',
|
|
'root_id': '',
|
|
'is_crt_enabled': true,
|
|
});
|
|
|
|
expect(threadCalled, isFalse);
|
|
expect(navServer, 'https://nexo.example.com');
|
|
expect(navChannel, 'channel-123');
|
|
},
|
|
);
|
|
|
|
test('opened event without server_url skips navigation callbacks', () {
|
|
var channelCalled = false;
|
|
var threadCalled = false;
|
|
plugin.onNavigateToChannel = (_, _) => channelCalled = true;
|
|
plugin.onNavigateToThread = (_, _) => threadCalled = true;
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.opened,
|
|
'channel_id': 'channel-123',
|
|
});
|
|
|
|
expect(channelCalled, isFalse);
|
|
expect(threadCalled, isFalse);
|
|
});
|
|
|
|
test('every event is forwarded to onNotification stream', () async {
|
|
final future = plugin.onNotification.first;
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.clear,
|
|
'channel_id': 'channel-123',
|
|
});
|
|
|
|
final raw = await future;
|
|
expect(raw['type'], PushNotificationType.clear);
|
|
expect(raw['channel_id'], 'channel-123');
|
|
});
|
|
|
|
test(
|
|
'token_refresh event saves formatted device token and invokes callback',
|
|
() async {
|
|
String? channelToken;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(actionChannel, (call) async {
|
|
if (call.method == 'saveDeviceToken') {
|
|
final args = call.arguments as Map;
|
|
channelToken = args['token'] as String?;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
final completer = Completer<String>();
|
|
plugin.onDeviceTokenReady = completer.complete;
|
|
|
|
plugin.handleNativeEvent({
|
|
'type': PushNotificationType.tokenRefresh,
|
|
'token': 'raw-token',
|
|
});
|
|
|
|
final callbackToken = await completer.future;
|
|
expect(channelToken, 'android_rn-v2:raw-token');
|
|
expect(callbackToken, 'android_rn-v2:raw-token');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'debugSendNativeEventForTesting invokes native debug method',
|
|
() async {
|
|
MethodCall? capturedCall;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(actionChannel, (call) async {
|
|
capturedCall = call;
|
|
return null;
|
|
});
|
|
|
|
await plugin.debugSendNativeEventForTesting({
|
|
'type': PushNotificationType.opened,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-debug',
|
|
});
|
|
|
|
expect(capturedCall?.method, 'debugSendNativeEvent');
|
|
expect(capturedCall?.arguments, {
|
|
'type': PushNotificationType.opened,
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'channel-debug',
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
group('Channel constants', () {
|
|
test('notification and action channel names are preserved', () {
|
|
expect(
|
|
NexoMessagingPlugin.notificationChannelName,
|
|
'com.tokilabs.nexo.messaging/notifications',
|
|
);
|
|
expect(
|
|
NexoMessagingPlugin.actionChannelName,
|
|
'com.tokilabs.nexo.messaging/notification_actions',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('NexoMessagingInitializeOptions', () {
|
|
test('has all default values set to true', () {
|
|
const options = NexoMessagingInitializeOptions();
|
|
expect(options.listenForTokenRefresh, isTrue);
|
|
expect(options.requestNotificationPermission, isTrue);
|
|
expect(options.registerDeviceToken, isTrue);
|
|
});
|
|
|
|
test('can override all fields to false', () {
|
|
const options = NexoMessagingInitializeOptions(
|
|
listenForTokenRefresh: false,
|
|
requestNotificationPermission: false,
|
|
registerDeviceToken: false,
|
|
);
|
|
expect(options.listenForTokenRefresh, isFalse);
|
|
expect(options.requestNotificationPermission, isFalse);
|
|
expect(options.registerDeviceToken, isFalse);
|
|
});
|
|
|
|
test('can override individual fields', () {
|
|
const options1 = NexoMessagingInitializeOptions(
|
|
requestNotificationPermission: false,
|
|
);
|
|
expect(options1.listenForTokenRefresh, isTrue);
|
|
expect(options1.requestNotificationPermission, isFalse);
|
|
expect(options1.registerDeviceToken, isTrue);
|
|
|
|
const options2 = NexoMessagingInitializeOptions(
|
|
listenForTokenRefresh: false,
|
|
registerDeviceToken: false,
|
|
);
|
|
expect(options2.listenForTokenRefresh, isFalse);
|
|
expect(options2.requestNotificationPermission, isTrue);
|
|
expect(options2.registerDeviceToken, isFalse);
|
|
});
|
|
});
|
|
|
|
group('NexoMessagingPlugin web notification permission', () {
|
|
late NexoMessagingPlugin plugin;
|
|
|
|
setUp(() {
|
|
plugin = NexoMessagingPlugin.instance;
|
|
});
|
|
|
|
test('reports unsupported status on non-web platforms', () async {
|
|
final status = await plugin.getWebNotificationPermissionStatus();
|
|
|
|
expect(status.isSupported, isFalse);
|
|
expect(
|
|
status.permission,
|
|
NexoMessagingWebNotificationPermission.unsupported,
|
|
);
|
|
expect(status.rawPermission, isNull);
|
|
expect(status.isGranted, isFalse);
|
|
});
|
|
|
|
test(
|
|
'request is a no-op unsupported status on non-web platforms',
|
|
() async {
|
|
final status = await plugin.requestWebNotificationPermission();
|
|
|
|
expect(status.isSupported, isFalse);
|
|
expect(
|
|
status.permission,
|
|
NexoMessagingWebNotificationPermission.unsupported,
|
|
);
|
|
expect(status.rawPermission, isNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('NexoMessagingTokenPrefix', () {
|
|
test('androidV2 has the expected value', () {
|
|
expect(NexoMessagingTokenPrefix.androidV2, 'android_rn-v2');
|
|
});
|
|
|
|
test('appleV2 has the expected value', () {
|
|
expect(NexoMessagingTokenPrefix.appleV2, 'apple_rn-v2');
|
|
});
|
|
|
|
test('webV2 has the expected value', () {
|
|
expect(NexoMessagingTokenPrefix.webV2, 'web_rn-v2');
|
|
});
|
|
});
|
|
|
|
group('normalizeWebForegroundMessage', () {
|
|
test('missing type defaults to PushNotificationType.message', () {
|
|
final result = normalizeWebForegroundMessage({
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'ch-1',
|
|
});
|
|
|
|
expect(result['type'], PushNotificationType.message);
|
|
});
|
|
|
|
test('existing type is preserved', () {
|
|
final result = normalizeWebForegroundMessage({
|
|
'type': PushNotificationType.clear,
|
|
});
|
|
|
|
expect(result['type'], PushNotificationType.clear);
|
|
});
|
|
|
|
test('userInteraction defaults to false when absent from payload', () {
|
|
final result = normalizeWebForegroundMessage({});
|
|
|
|
expect(result['userInteraction'], isFalse);
|
|
});
|
|
|
|
test('userInteraction parameter is applied when payload does not set it', () {
|
|
final result = normalizeWebForegroundMessage({}, userInteraction: true);
|
|
|
|
expect(result['userInteraction'], isTrue);
|
|
});
|
|
|
|
test('userInteraction in payload overrides parameter', () {
|
|
final result = normalizeWebForegroundMessage(
|
|
{'userInteraction': true},
|
|
userInteraction: false,
|
|
);
|
|
|
|
expect(result['userInteraction'], isTrue);
|
|
});
|
|
|
|
test('routing fields are preserved for NotificationOpenedEvent.fromMap', () {
|
|
final result = normalizeWebForegroundMessage({
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'ch-1',
|
|
'root_id': 'root-99',
|
|
'is_crt_enabled': 'true',
|
|
});
|
|
|
|
final event = NotificationOpenedEvent.fromMap(result);
|
|
expect(event.serverUrl, 'https://nexo.example.com');
|
|
expect(event.channelId, 'ch-1');
|
|
expect(event.rootId, 'root-99');
|
|
expect(event.isCRTEnabled, isTrue);
|
|
});
|
|
|
|
test('null routing field values are not included in result', () {
|
|
final result = normalizeWebForegroundMessage({
|
|
'server_url': null,
|
|
'channel_id': 'ch-1',
|
|
});
|
|
|
|
expect(result.containsKey('server_url'), isFalse);
|
|
expect(result['channel_id'], 'ch-1');
|
|
});
|
|
|
|
test('absent routing fields are not included in result', () {
|
|
final result = normalizeWebForegroundMessage({'type': PushNotificationType.message});
|
|
|
|
expect(result.containsKey('server_url'), isFalse);
|
|
expect(result.containsKey('channel_id'), isFalse);
|
|
expect(result.containsKey('root_id'), isFalse);
|
|
expect(result.containsKey('is_crt_enabled'), isFalse);
|
|
});
|
|
|
|
test('message stable fields are preserved in normalized result', () {
|
|
final result = normalizeWebForegroundMessage({
|
|
'server_url': 'https://nexo.example.com',
|
|
'channel_id': 'ch-1',
|
|
'server_id': 'srv-99',
|
|
'ack_id': 'ack-42',
|
|
'post_id': 'post-77',
|
|
'signature': 'sig-abc',
|
|
'id_loaded': 'true',
|
|
'data': {'key': 'value'},
|
|
});
|
|
|
|
expect(result['server_url'], 'https://nexo.example.com');
|
|
expect(result['channel_id'], 'ch-1');
|
|
expect(result['server_id'], 'srv-99');
|
|
expect(result['ack_id'], 'ack-42');
|
|
expect(result['post_id'], 'post-77');
|
|
expect(result['signature'], 'sig-abc');
|
|
expect(result['id_loaded'], 'true');
|
|
expect(result['data'], {'key': 'value'});
|
|
});
|
|
|
|
test('unknown extra fields from payload are preserved', () {
|
|
final result = normalizeWebForegroundMessage({
|
|
'custom_field': 'custom_value',
|
|
'another': 123,
|
|
});
|
|
|
|
expect(result['custom_field'], 'custom_value');
|
|
expect(result['another'], 123);
|
|
});
|
|
});
|
|
|
|
group('showWebForegroundNotification fake interop', () {
|
|
late NexoMessagingPlugin plugin;
|
|
late FakeBrowserNotificationInterop fake;
|
|
|
|
void setUpWith({bool isSupported = true, String perm = 'granted'}) {
|
|
final ac = MethodChannel(NexoMessagingPlugin.actionChannelName);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(ac, (call) async => null);
|
|
fake = FakeBrowserNotificationInterop(
|
|
isSupportedParam: isSupported,
|
|
permissionParam: perm,
|
|
);
|
|
plugin = NexoMessagingPlugin.instance;
|
|
plugin.resetForTesting();
|
|
plugin.interopForTesting = fake;
|
|
plugin.onNavigateToChannel = null;
|
|
plugin.onNavigateToThread = null;
|
|
plugin.onDeviceTokenReady = null;
|
|
}
|
|
|
|
setUp(() => setUpWith());
|
|
tearDown(() {
|
|
final ac = MethodChannel(NexoMessagingPlugin.actionChannelName);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(ac, null);
|
|
});
|
|
|
|
test('unsupported platform returns unsupported result', () {
|
|
setUpWith(isSupported: false);
|
|
final r = plugin.showWebForegroundNotification({'title': 'H'});
|
|
expect(r.isSupported, isFalse);
|
|
expect(r.success, isFalse);
|
|
expect(r.permissionGranted, isFalse);
|
|
});
|
|
|
|
test('denied permission returns no-op result', () {
|
|
setUpWith(isSupported: true, perm: 'denied');
|
|
final r = plugin.showWebForegroundNotification({'title': 'H'});
|
|
expect(r.isSupported, isTrue);
|
|
expect(r.success, isFalse);
|
|
expect(r.permissionGranted, isFalse);
|
|
});
|
|
|
|
test('granted triggers interop.show()', () {
|
|
setUpWith(isSupported: true, perm: 'granted');
|
|
final r = plugin.showWebForegroundNotification({
|
|
'title': 'T', 'body': 'B', 'tag': 'X',
|
|
});
|
|
expect(r.success, isTrue);
|
|
expect(fake.showCalled, isTrue);
|
|
expect(fake.lastShowOptions!['title'], 'T');
|
|
expect(fake.lastShowOptions!['body'], 'B');
|
|
});
|
|
|
|
test('interop.show() receives routing fields', () {
|
|
setUpWith();
|
|
plugin.showWebForegroundNotification({
|
|
'title': 'T', 'server_url': 'https://x.com',
|
|
'channel_id': 'c1', 'root_id': 'r1', 'is_crt_enabled': 'true',
|
|
});
|
|
final opts = fake.lastShowOptions!;
|
|
expect(opts['server_url'], 'https://x.com');
|
|
expect(opts['channel_id'], 'c1');
|
|
expect(opts['root_id'], 'r1');
|
|
expect(opts['is_crt_enabled'], 'true');
|
|
});
|
|
|
|
test('click routes onNotificationOpened + onNavigateToChannel', () {
|
|
setUpWith();
|
|
String? srv, ch;
|
|
plugin.onNavigateToChannel = (s, c) { srv = s; ch = c; };
|
|
plugin.showWebForegroundNotification({
|
|
'title': 'C', 'server_url': 'https://x.com',
|
|
'channel_id': 'abc',
|
|
});
|
|
fake.triggerClick({
|
|
'server_url': 'https://x.com', 'channel_id': 'abc',
|
|
});
|
|
expect(srv, 'https://x.com');
|
|
expect(ch, 'abc');
|
|
});
|
|
|
|
test('click routes onNavigateToThread when CRT', () {
|
|
setUpWith();
|
|
String? srv, rt;
|
|
plugin.onNavigateToThread = (s, r) { srv = s; rt = r; };
|
|
plugin.showWebForegroundNotification({
|
|
'title': 'CRT', 'server_url': 'https://x.com',
|
|
'channel_id': 'ch1', 'root_id': 'root-99', 'is_crt_enabled': 'true',
|
|
});
|
|
fake.triggerClick({
|
|
'server_url': 'https://x.com', 'channel_id': 'ch1',
|
|
'root_id': 'root-99', 'is_crt_enabled': 'true',
|
|
});
|
|
expect(srv, 'https://x.com');
|
|
expect(rt, 'root-99');
|
|
});
|
|
|
|
test('click emits opened event on stream', () async {
|
|
setUpWith();
|
|
final fut = plugin.onNotificationOpened.first;
|
|
plugin.showWebForegroundNotification({
|
|
'title': 'S', 'server_url': 'https://x.com',
|
|
'channel_id': 'cstream',
|
|
});
|
|
fake.triggerClick({
|
|
'server_url': 'https://x.com', 'channel_id': 'cstream',
|
|
});
|
|
final e = await fut;
|
|
expect(e.serverUrl, 'https://x.com');
|
|
expect(e.channelId, 'cstream');
|
|
});
|
|
|
|
test('display forwards to onNotification stream', () async {
|
|
setUpWith();
|
|
final fut = plugin.onNotification.first;
|
|
plugin.showWebForegroundNotification({
|
|
'title': 'P', 'custom_field': 'cv',
|
|
});
|
|
final raw = await fut;
|
|
expect(raw['type'], PushNotificationType.message);
|
|
expect(raw['custom_field'], 'cv');
|
|
});
|
|
});
|
|
}
|