From 557f2e1fc1704bb994a49d77619d53a8485e265a Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 25 May 2026 21:16:56 +0900 Subject: [PATCH] update mattermost push plugin implementation and examples --- README.md | 8 +- .../MattermostPushPlugin.kt | 12 +++ example/README.md | 4 + .../plugin_integration_test.dart | 63 ++++++++++++- example/lib/main.dart | 55 ++++++++++- example/test/widget_test.dart | 93 +++++++++++++++++-- lib/src/mattermost_push_plugin.dart | 11 +++ test/mattermost_push_plugin_test.dart | 25 +++++ 8 files changed, 257 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 190af89..d2a74ec 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,10 @@ cd example/android ## Test Strategy +Use deterministic tests for repository-owned behavior. The example integration +test can inject a debug native event through the plugin method channel so the +EventChannel and opened-routing path can run without Firebase FCM. + Use the repository tests as the source of truth for plugin behavior: 1. Root `test/` covers Dart API behavior, event parsing, callback routing, and @@ -222,9 +226,9 @@ Use the repository tests as the source of truth for plugin behavior: 2. `android/src/test/` should cover native payload parsing, signing-key lookup, local storage, notification behavior, and ACK request behavior. 3. `example/test/` covers the thin Flutter harness app. -4. `example/integration_test/` should cover real plugin registration, +4. `example/integration_test/` covers real plugin registration, method-channel behavior, event-channel behavior, notification-open routing, - and manifest integration on a device or emulator. + and manifest integration on a device or emulator using the test/debug injection path. 5. Manual smoke tests cover true Firebase FCM delivery and real Mattermost ACK flows because they depend on external infrastructure. diff --git a/android/src/main/kotlin/com/tokilabs/mattermost_push_plugin/MattermostPushPlugin.kt b/android/src/main/kotlin/com/tokilabs/mattermost_push_plugin/MattermostPushPlugin.kt index 55c1936..7d06a28 100644 --- a/android/src/main/kotlin/com/tokilabs/mattermost_push_plugin/MattermostPushPlugin.kt +++ b/android/src/main/kotlin/com/tokilabs/mattermost_push_plugin/MattermostPushPlugin.kt @@ -136,6 +136,18 @@ class MattermostPushPlugin : override fun onMethodCall(call: MethodCall, result: Result) { val context = applicationContext when (call.method) { + "debugSendNativeEvent" -> { + val arguments = call.arguments as? Map<*, *> + if (arguments != null) { + val payload = arguments.entries + .filter { it.key is String } + .associate { it.key as String to it.value } + PushNotificationEvents.send(payload) + result.success(null) + } else { + result.error("INVALID_ARG", "event payload is null", null) + } + } "saveDeviceToken" -> { val token = call.argument("token") if (token != null) { diff --git a/example/README.md b/example/README.md index ac88310..53ebe68 100644 --- a/example/README.md +++ b/example/README.md @@ -50,6 +50,10 @@ Firebase configuration, including `android/app/google-services.json`. ## Verification +The integration test uses the plugin's test/debug event injection path for +method-channel, event-channel, and opened-routing checks. Real FCM delivery, +ACK, inline reply, and dismiss behavior remain manual smoke checks. + Run checks from this directory: ```sh diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index dfa71fe..3b2d8a4 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -1,14 +1,71 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; - import 'package:mattermost_push_plugin/mattermost_push_plugin.dart'; +import 'package:mattermost_push_plugin_example/main.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('singleton instance is accessible', (WidgetTester tester) async { expect(MattermostPushPlugin.instance, isNotNull); - expect(MattermostPushPlugin.notificationChannelName, - 'com.tokilabs.mattermost/notifications'); + expect( + MattermostPushPlugin.notificationChannelName, + 'com.tokilabs.mattermost/notifications', + ); + }); + + testWidgets('native debug event reaches harness routing UI (channel)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MyApp()); + + MattermostPushPlugin.instance.listenNativeChannelForTesting(); + await MattermostPushPlugin.instance.debugSendNativeEventForTesting(const { + 'type': PushNotificationType.opened, + 'server_url': 'https://mm.example.com', + 'channel_id': 'channel-integration', + }); + + await tester.pumpAndSettle(); + + expect(find.textContaining('Last notification:'), findsOneWidget); + expect(find.textContaining('channel-integration'), findsWidgets); + expect( + find.text( + 'Last navigation: channel:https://mm.example.com/channel-integration', + ), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('native debug event reaches harness routing UI (thread/CRT)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MyApp()); + + MattermostPushPlugin.instance.listenNativeChannelForTesting(); + await MattermostPushPlugin.instance.debugSendNativeEventForTesting(const { + 'type': PushNotificationType.opened, + 'server_url': 'https://mm.example.com', + 'channel_id': 'channel-integration', + 'root_id': 'root-integration', + 'is_crt_enabled': 'true', + }); + + await tester.pumpAndSettle(); + + expect(find.textContaining('Last notification:'), findsOneWidget); + expect(find.textContaining('root-integration'), findsWidgets); + expect( + find.text( + 'Last navigation: thread:https://mm.example.com/root-integration', + ), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); }); } diff --git a/example/lib/main.dart b/example/lib/main.dart index ad992b3..c3c756f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:mattermost_push_plugin/mattermost_push_plugin.dart'; @@ -14,14 +16,55 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { String? _deviceToken; + String? _lastNotification; + String? _lastOpened; + String? _lastNavigation; + StreamSubscription>? _notificationSubscription; + StreamSubscription? _openedSubscription; @override void initState() { super.initState(); - MattermostPushPlugin.instance.onDeviceTokenReady = (token) { + final plugin = MattermostPushPlugin.instance; + plugin.onDeviceTokenReady = (token) { if (!mounted) return; setState(() => _deviceToken = token); }; + plugin.onNavigateToChannel = (serverUrl, channelId) { + if (!mounted) return; + setState(() => _lastNavigation = 'channel:$serverUrl/$channelId'); + }; + plugin.onNavigateToThread = (serverUrl, rootId) { + if (!mounted) return; + setState(() => _lastNavigation = 'thread:$serverUrl/$rootId'); + }; + _notificationSubscription = plugin.onNotification.listen((event) { + if (!mounted) return; + setState(() => _lastNotification = _formatMap(event)); + }); + _openedSubscription = plugin.onNotificationOpened.listen((event) { + if (!mounted) return; + setState(() => _lastOpened = _formatOpened(event)); + }); + } + + @override + void dispose() { + _notificationSubscription?.cancel(); + _openedSubscription?.cancel(); + final plugin = MattermostPushPlugin.instance; + plugin.onDeviceTokenReady = null; + plugin.onNavigateToChannel = null; + plugin.onNavigateToThread = null; + super.dispose(); + } + + String _formatMap(Map map) { + return map.entries.map((e) => '${e.key}: ${e.value}').join(', '); + } + + String _formatOpened(NotificationOpenedEvent event) { + return 'server_url: ${event.serverUrl}, channel_id: ${event.channelId}, root_id: ${event.rootId}, is_crt_enabled: ${event.isCRTEnabled}'; } @override @@ -29,8 +72,14 @@ class _MyAppState extends State { return MaterialApp( home: Scaffold( appBar: AppBar(title: const Text('Mattermost push plugin example')), - body: Center( - child: Text('Device token: ${_deviceToken ?? 'pending'}'), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Device token: ${_deviceToken ?? 'pending'}'), + Text('Last notification: ${_lastNotification ?? 'none'}'), + Text('Last opened: ${_lastOpened ?? 'none'}'), + Text('Last navigation: ${_lastNavigation ?? 'none'}'), + ], ), ), ); diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index bfcef05..8974774 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,19 +1,100 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - +import 'package:mattermost_push_plugin/mattermost_push_plugin.dart'; import 'package:mattermost_push_plugin_example/main.dart'; void main() { - testWidgets('renders pending device token placeholder', - (WidgetTester tester) async { + testWidgets('renders initial harness state', (WidgetTester tester) async { await tester.pumpWidget(const MyApp()); + expect(find.text('Device token: pending'), findsOneWidget); + expect(find.text('Last notification: none'), findsOneWidget); + expect(find.text('Last opened: none'), findsOneWidget); + expect(find.text('Last navigation: none'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('updates opened and navigation state from plugin events (channel)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MyApp()); + + MattermostPushPlugin.instance.handleNativeEvent(const { + 'type': PushNotificationType.opened, + 'server_url': 'https://mm.example.com', + 'channel_id': 'channel-123', + 'is_crt_enabled': 'false', + }); + await tester.pump(); + expect( - find.byWidgetPredicate( - (Widget widget) => - widget is Text && (widget.data?.startsWith('Device token:') ?? false), + find.text( + 'Last notification: type: opened, server_url: https://mm.example.com, channel_id: channel-123, is_crt_enabled: false', ), findsOneWidget, ); + expect( + find.text( + 'Last opened: server_url: https://mm.example.com, channel_id: channel-123, root_id: null, is_crt_enabled: false', + ), + findsOneWidget, + ); + expect( + find.text('Last navigation: channel:https://mm.example.com/channel-123'), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets( + 'updates opened and navigation state from plugin events (thread/CRT)', + (WidgetTester tester) async { + await tester.pumpWidget(const MyApp()); + + MattermostPushPlugin.instance.handleNativeEvent(const { + 'type': PushNotificationType.opened, + 'server_url': 'https://mm.example.com', + 'channel_id': 'channel-123', + 'root_id': 'thread-456', + 'is_crt_enabled': 'true', + }); + await tester.pump(); + + expect( + find.text( + 'Last notification: type: opened, server_url: https://mm.example.com, channel_id: channel-123, root_id: thread-456, is_crt_enabled: true', + ), + findsOneWidget, + ); + expect( + find.text( + 'Last opened: server_url: https://mm.example.com, channel_id: channel-123, root_id: thread-456, is_crt_enabled: true', + ), + findsOneWidget, + ); + expect( + find.text('Last navigation: thread:https://mm.example.com/thread-456'), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets('updates device token from plugin callback', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MyApp()); + + MattermostPushPlugin.instance.onDeviceTokenReady?.call( + 'test-device-token-123', + ); + await tester.pump(); + + expect(find.text('Device token: test-device-token-123'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); }); } diff --git a/lib/src/mattermost_push_plugin.dart b/lib/src/mattermost_push_plugin.dart index e8346ee..df834d1 100644 --- a/lib/src/mattermost_push_plugin.dart +++ b/lib/src/mattermost_push_plugin.dart @@ -58,6 +58,7 @@ class MattermostPushPlugin { } void _listenNativeChannel() { + if (_channelSubscription != null) return; _channelSubscription = _notificationChannel.receiveBroadcastStream().listen( (dynamic event) { if (event is Map) { @@ -69,6 +70,16 @@ class MattermostPushPlugin { ); } + @visibleForTesting + void listenNativeChannelForTesting() { + _listenNativeChannel(); + } + + @visibleForTesting + Future debugSendNativeEventForTesting(Map data) async { + await _actionChannel.invokeMethod('debugSendNativeEvent', data); + } + void _listenFcmTokenRefresh() { FirebaseMessaging.instance.onTokenRefresh.listen((token) async { await _saveDeviceToken(token); diff --git a/test/mattermost_push_plugin_test.dart b/test/mattermost_push_plugin_test.dart index b9edadf..73fcbee 100644 --- a/test/mattermost_push_plugin_test.dart +++ b/test/mattermost_push_plugin_test.dart @@ -162,6 +162,31 @@ void main() { 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://mm.example.com', + 'channel_id': 'channel-debug', + }); + + expect(capturedCall?.method, 'debugSendNativeEvent'); + expect(capturedCall?.arguments, { + 'type': PushNotificationType.opened, + 'server_url': 'https://mm.example.com', + 'channel_id': 'channel-debug', + }); + }, + ); }); group('Channel constants', () {