nexo/packages/messaging_flutter/test/nexo_messaging_test.dart
toki 079c2984bd feat(messaging): Flutter Web 알림 마일스톤을 완료 상태로 승격하고 알림 라우팅을 개선한다
- PHASE.md에서 flutter-web-notification-smoke 마일스톤 상태를 완료로 변경하고 archive로 이동
- browser_notification_interop_web.dart에서 라우팅 데이터 캡처 방식을 인스턴스 변수에서 인자 전달로 변경하여 각 알림별 데이터 격리
- nexo_messaging_plugin.dart에서 permissionGranted를 고정 값에서 실제 권한 상태 기반으로 변경
- 코드 스타일 개선: if문 블록 포맷팅, 단일 줄 최적화
- 테스트 추가: show 실패 시 최신 권한 상태를 반환하는 검증
2026-06-10 20:40:17 +09:00

733 lines
23 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,
String permissionParam = 'granted',
this.showReturnValue = true,
String? permissionAfterShow,
}) : _isSupported = isSupportedParam,
_permission = permissionParam,
_permissionAfterShow = permissionAfterShow;
final bool _isSupported;
String _permission;
final String? _permissionAfterShow;
final bool isSupportedParam;
final bool showReturnValue;
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);
if (_permissionAfterShow != null) {
_permission = _permissionAfterShow;
}
return showReturnValue;
}
@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('NexoMessagingWebNotificationStatus', () {
test('normalizes browser permission values', () {
final granted = NexoMessagingWebNotificationStatus.fromBrowserPermission(
'granted',
);
final denied = NexoMessagingWebNotificationStatus.fromBrowserPermission(
'denied',
);
final defaultPermission =
NexoMessagingWebNotificationStatus.fromBrowserPermission('default');
expect(granted.isSupported, isTrue);
expect(
granted.permission,
NexoMessagingWebNotificationPermission.granted,
);
expect(granted.rawPermission, 'granted');
expect(granted.isGranted, isTrue);
expect(denied.permission, NexoMessagingWebNotificationPermission.denied);
expect(denied.isGranted, isFalse);
expect(
defaultPermission.permission,
NexoMessagingWebNotificationPermission.defaultPermission,
);
});
test('trims and preserves unknown browser permission values', () {
final status = NexoMessagingWebNotificationStatus.fromBrowserPermission(
' prompt ',
);
expect(status.isSupported, isTrue);
expect(status.permission, NexoMessagingWebNotificationPermission.unknown);
expect(status.rawPermission, ' prompt ');
expect(status.isGranted, isFalse);
});
test('unsupported status is not granted', () {
const status = NexoMessagingWebNotificationStatus.unsupported();
expect(status.isSupported, isFalse);
expect(
status.permission,
NexoMessagingWebNotificationPermission.unsupported,
);
expect(status.rawPermission, isNull);
expect(status.isGranted, isFalse);
});
});
group('NexoMessagingWebNotificationDisplayResult', () {
test('unsupported display result is a no-op failure', () {
const result = NexoMessagingWebNotificationDisplayResult.unsupported();
expect(result.success, isFalse);
expect(result.isSupported, isFalse);
expect(result.permissionGranted, isFalse);
});
});
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',
bool showReturnValue = true,
String? permissionAfterShow,
}) {
final ac = MethodChannel(NexoMessagingPlugin.actionChannelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(ac, (call) async => null);
fake = FakeBrowserNotificationInterop(
isSupportedParam: isSupported,
permissionParam: perm,
showReturnValue: showReturnValue,
permissionAfterShow: permissionAfterShow,
);
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('show failure reads latest permission before returning', () {
setUpWith(
isSupported: true,
perm: 'granted',
showReturnValue: false,
permissionAfterShow: 'denied',
);
final r = plugin.showWebForegroundNotification({'title': 'H'});
expect(r.success, isFalse);
expect(r.isSupported, isTrue);
expect(r.permissionGranted, isFalse);
});
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');
});
});
}