update mattermost push plugin implementation and examples
This commit is contained in:
parent
c13bb40b68
commit
557f2e1fc1
8 changed files with 257 additions and 14 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>("token")
|
||||
if (token != null) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MyApp> {
|
||||
String? _deviceToken;
|
||||
String? _lastNotification;
|
||||
String? _lastOpened;
|
||||
String? _lastNavigation;
|
||||
StreamSubscription<Map<String, dynamic>>? _notificationSubscription;
|
||||
StreamSubscription<NotificationOpenedEvent>? _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<String, dynamic> 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<MyApp> {
|
|||
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'}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> debugSendNativeEventForTesting(Map<String, dynamic> data) async {
|
||||
await _actionChannel.invokeMethod('debugSendNativeEvent', data);
|
||||
}
|
||||
|
||||
void _listenFcmTokenRefresh() {
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
|
||||
await _saveDeviceToken(token);
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue