diff --git a/lib/core/app.dart b/lib/core/app.dart index c7399f31..ef1e6ddf 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -166,11 +166,12 @@ class _MostroAppState extends ConsumerState { if (!_notificationLaunchHandled && _router != null) { _notificationLaunchHandled = true; WidgetsBinding.instance.addPostFrameCallback((_) async { - final orderId = await getNotificationLaunchOrderId(); + final payload = await getNotificationLaunchOrderId(); if (!mounted) return; - if (orderId != null && orderId.isNotEmpty) { - debugPrint('App launched from notification tap, navigating to order: $orderId'); - _router!.push('/trade_detail/$orderId'); + if (payload != null && payload.isNotEmpty) { + final route = resolveNotificationRoute(payload); + debugPrint('App launched from notification tap, navigating to: $route'); + _router!.push(route); } }); } diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index e8e8574b..da6caf38 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -21,6 +21,7 @@ class Session { NostrKeyPairs? _sharedKey; String? _adminPubkey; NostrKeyPairs? _adminSharedKey; + String? disputeId; Session({ required this.masterKey, @@ -31,6 +32,7 @@ class Session { this.orderId, this.parentOrderId, this.role, + this.disputeId, Peer? peer, String? adminPubkey, }) { @@ -56,6 +58,7 @@ class Session { 'role': role?.value, 'peer': peer?.publicKey, 'admin_peer': _adminPubkey, + 'dispute_id': disputeId, }; factory Session.fromJson(Map json) { @@ -166,6 +169,13 @@ class Session { } } + // Parse optional dispute ID + String? disputeId; + final disputeIdValue = json['dispute_id']; + if (disputeIdValue != null && disputeIdValue is String && disputeIdValue.isNotEmpty) { + disputeId = disputeIdValue; + } + return Session( masterKey: masterKeyValue, tradeKey: tradeKeyValue, @@ -177,6 +187,7 @@ class Session { role: role, peer: peer, adminPubkey: adminPubkey, + disputeId: disputeId, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 83c36daa..68ab6ef7 100644 --- a/lib/features/notifications/services/background_notification_service.dart +++ b/lib/features/notifications/services/background_notification_service.dart @@ -18,6 +18,7 @@ import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_storage.dart'; import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'package:mostro_mobile/features/notifications/utils/notification_message_mapper.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/generated/l10n_de.dart'; @@ -57,6 +58,39 @@ Future getNotificationLaunchOrderId() async { return null; } +/// Resolves the navigation route from a notification payload string. +/// +/// Returns the route path to navigate to. Pure function, no side effects. +String resolveNotificationRoute(String? payload) { + if (payload == null || payload.isEmpty) { + return '/notifications'; + } + + try { + final decoded = jsonDecode(payload); + if (decoded is! Map) { + throw FormatException('Payload is not a JSON object'); + } + + final type = decoded['type'] as String?; + final orderId = decoded['orderId'] as String?; + final disputeId = decoded['disputeId'] as String?; + + if (type == 'admin_dm' && orderId != null) { + if (disputeId != null) { + return '/dispute_details/$disputeId'; + } + return '/trade_detail/$orderId'; + } + } on FormatException { + // Not JSON — treat as plain orderId (legacy format) + } catch (e) { + logger.e('Unexpected error parsing notification payload: $e'); + } + + return '/trade_detail/$payload'; +} + void _onNotificationTap(NotificationResponse response) { try { final context = MostroApp.navigatorKey.currentContext; @@ -65,14 +99,9 @@ void _onNotificationTap(NotificationResponse response) { return; } - final orderId = response.payload; - if (orderId != null && orderId.isNotEmpty) { - context.push('/trade_detail/$orderId'); - logger.i('Navigated to trade detail for order: $orderId'); - } else { - context.push('/notifications'); - logger.i('Navigated to notifications screen'); - } + final route = resolveNotificationRoute(response.payload); + context.push(route); + logger.i('Navigated to: $route'); } catch (e) { logger.e('Navigation error: $e'); } @@ -129,13 +158,22 @@ Future showLocalNotification(NostrEvent event) async { ), ); + // Build payload: JSON for admin DMs, plain orderId for standard notifications + final notificationPayload = notificationData.action == mostro_action.Action.sendDm + ? jsonEncode({ + 'type': 'admin_dm', + 'orderId': mostroMessage.id, + 'disputeId': matchingSession?.disputeId, + }) + : mostroMessage.id; + // Use fixed ID (0) with tag for replacement - Android uses tag+id combo await flutterLocalNotificationsPlugin.show( 0, // Fixed ID - tag 'mostro-trade' makes it unique and replaceable notificationText.title, notificationText.body, details, - payload: mostroMessage.id, + payload: notificationPayload, ); logger.i('Shown: ${notificationText.title} - ${notificationText.body}'); @@ -153,6 +191,17 @@ Future _decryptAndProcessEvent(NostrEvent event) async { final sessions = await _loadSessionsFromDatabase(); + // Try matching by adminSharedKey first (dispute chat DMs) + final adminSession = sessions.cast().firstWhere( + (s) => s?.adminSharedKey?.public == event.recipient, + orElse: () => null, + ); + + if (adminSession != null) { + return _processAdminDm(event, adminSession); + } + + // Standard Mostro message: match by tradeKey final matchingSession = sessions.cast().firstWhere( (s) => s?.tradeKey.public == event.recipient, orElse: () => null, @@ -172,6 +221,20 @@ Future _decryptAndProcessEvent(NostrEvent event) async { return null; } + // Detect admin/dispute DM format that arrived via tradeKey + final firstItem = result[0]; + if (NostrUtils.isDmPayload(firstItem)) { + if (matchingSession.orderId == null) { + logger.w('DM received but session has no orderId (recipient: ${event.recipient}), skipping notification'); + return null; + } + return MostroMessage( + action: mostro_action.Action.sendDm, + id: matchingSession.orderId, + timestamp: event.createdAt?.millisecondsSinceEpoch, + ); + } + final mostroMessage = MostroMessage.fromJson(result[0]); mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch; @@ -182,6 +245,29 @@ Future _decryptAndProcessEvent(NostrEvent event) async { } } +Future _processAdminDm(NostrEvent event, Session session) async { + try { + final unwrapped = await event.p2pUnwrap(session.adminSharedKey!); + if (unwrapped.content == null || unwrapped.content!.isEmpty) { + return null; + } + + if (session.orderId == null) { + logger.w('Admin DM received but session has no orderId (recipient: ${event.recipient}), skipping notification'); + return null; + } + + return MostroMessage( + action: mostro_action.Action.sendDm, + id: session.orderId, + timestamp: event.createdAt?.millisecondsSinceEpoch, + ); + } catch (e) { + logger.e('Admin DM decrypt error: $e'); + return null; + } +} + Future> _loadSessionsFromDatabase() async { try { final db = await openMostroDatabase('mostro.db'); diff --git a/lib/features/notifications/utils/notification_data_extractor.dart b/lib/features/notifications/utils/notification_data_extractor.dart index 34278eee..c437d6e8 100644 --- a/lib/features/notifications/utils/notification_data_extractor.dart +++ b/lib/features/notifications/utils/notification_data_extractor.dart @@ -177,20 +177,28 @@ class NotificationDataExtractor { // No additional values needed break; + case Action.sendDm: + // Admin/dispute DM — no payload extraction needed, generic message + break; + + case Action.cooperativeCancelAccepted: + // No additional values needed + break; + case Action.cantDo: final cantDo = event.getPayload(); values['reason'] = cantDo?.cantDoReason.toString(); isTemporary = true; // cantDo notifications are temporary break; - + case Action.rate: // No additional values needed break; - + case Action.rateReceived: // This action doesn't generate notifications return null; - + default: // Unknown actions generate temporary notifications isTemporary = true; diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index 26e5f22e..4f13fd6a 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -70,7 +70,7 @@ class NotificationMessageMapper { case mostro.Action.cooperativeCancelAccepted: return 'notification_cooperative_cancel_accepted_title'; case mostro.Action.sendDm: - return 'notification_new_message_title'; + return 'notification_admin_message_title'; case mostro.Action.cancel: case mostro.Action.adminCancel: case mostro.Action.adminCanceled: @@ -166,7 +166,7 @@ class NotificationMessageMapper { case mostro.Action.cooperativeCancelAccepted: return 'notification_cooperative_cancel_accepted_message'; case mostro.Action.sendDm: - return 'notification_new_message_message'; + return 'notification_admin_message_message'; case mostro.Action.cancel: case mostro.Action.adminCancel: case mostro.Action.adminCanceled: @@ -330,6 +330,10 @@ class NotificationMessageMapper { return s.notification_new_message_title; case 'notification_new_message_message': return s.notification_new_message_message; + case 'notification_admin_message_title': + return s.notification_admin_message_title; + case 'notification_admin_message_message': + return s.notification_admin_message_message; case 'notification_order_update_title': return s.notification_order_update_title; case 'notification_order_update_message': diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index 12f92dd0..775bcc7e 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -404,6 +404,12 @@ class AbstractMostroNotifier extends StateNotifier { // Save dispute in state for listing state = state.copyWith(dispute: disputeWithOrderId); + // Persist disputeId on session for background notification routing + final sessionNotifierForDispute = ref.read(sessionNotifierProvider.notifier); + await sessionNotifierForDispute.updateSession( + orderId, (s) => s.disputeId = disputeWithOrderId.disputeId, + ); + // Notification handled by centralized NotificationDataExtractor path if (kDebugMode) { logger.i( @@ -487,6 +493,12 @@ class AbstractMostroNotifier extends StateNotifier { // Save dispute in state for listing state = state.copyWith(dispute: disputeWithOrderId); + // Persist disputeId on session for background notification routing + final sessionNotifierForPeerDispute = ref.read(sessionNotifierProvider.notifier); + await sessionNotifierForPeerDispute.updateSession( + orderId, (s) => s.disputeId = disputeWithOrderId.disputeId, + ); + // Notification handled by centralized NotificationDataExtractor path if (kDebugMode) { logger.i( diff --git a/lib/features/subscriptions/subscription_manager.dart b/lib/features/subscriptions/subscription_manager.dart index 37a9bb45..ea08f5f7 100644 --- a/lib/features/subscriptions/subscription_manager.dart +++ b/lib/features/subscriptions/subscription_manager.dart @@ -23,10 +23,12 @@ class SubscriptionManager { final _ordersController = StreamController.broadcast(); final _chatController = StreamController.broadcast(); + final _disputeChatController = StreamController.broadcast(); final _relayListController = StreamController.broadcast(); Stream get orders => _ordersController.stream; Stream get chat => _chatController.stream; + Stream get disputeChat => _disputeChatController.stream; Stream get relayList => _relayListController.stream; SubscriptionManager(this.ref) { @@ -141,6 +143,16 @@ class SubscriptionManager { .map((s) => s.sharedKey!.public) .toList(), ); + case SubscriptionType.disputeChat: + final adminKeys = sessions + .where((s) => s.adminSharedKey?.public != null) + .map((s) => s.adminSharedKey!.public) + .toList(); + if (adminKeys.isEmpty) return null; + return NostrFilter( + kinds: [1059], + p: adminKeys, + ); case SubscriptionType.relayList: // Relay list subscriptions are handled separately via subscribeToMostroRelayList return null; @@ -156,6 +168,9 @@ class SubscriptionManager { case SubscriptionType.chat: _chatController.add(event); break; + case SubscriptionType.disputeChat: + _disputeChatController.add(event); + break; case SubscriptionType.relayList: final relayListEvent = RelayListEvent.fromEvent(event); if (relayListEvent != null) { @@ -207,6 +222,8 @@ class SubscriptionManager { return orders; case SubscriptionType.chat: return chat; + case SubscriptionType.disputeChat: + return disputeChat; case SubscriptionType.relayList: // RelayList subscriptions should use subscribeToMostroRelayList() instead throw UnsupportedError('Use subscribeToMostroRelayList() for relay list subscriptions'); @@ -328,6 +345,7 @@ class SubscriptionManager { unsubscribeAll(); _ordersController.close(); _chatController.close(); + _disputeChatController.close(); _relayListController.close(); } } diff --git a/lib/features/subscriptions/subscription_type.dart b/lib/features/subscriptions/subscription_type.dart index f5d464bc..859e320b 100644 --- a/lib/features/subscriptions/subscription_type.dart +++ b/lib/features/subscriptions/subscription_type.dart @@ -1,5 +1,6 @@ enum SubscriptionType { chat, orders, + disputeChat, relayList, } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index bcace0a2..5eba6f70 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1176,6 +1176,8 @@ "notification_cant_do_message": "Die angeforderte Aktion kann nicht ausgeführt werden", "notification_new_message_title": "Neue Nachricht", "notification_new_message_message": "Du hast eine neue Nachricht erhalten", + "notification_admin_message_title": "Nachricht vom Administrator", + "notification_admin_message_message": "Du hast eine Nachricht vom Streitadministrator erhalten", "notification_order_update_title": "Order-Update", "notification_order_update_message": "Deine Order wurde aktualisiert", "@_comment_notification_details": "Labels für Benachrichtigungsdetails", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e734309a..42409719 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1176,6 +1176,8 @@ "notification_cant_do_message": "The requested action cannot be performed", "notification_new_message_title": "New message", "notification_new_message_message": "You have received a new message", + "notification_admin_message_title": "Message from admin", + "notification_admin_message_message": "You have received a message from the dispute admin", "notification_order_update_title": "Order update", "notification_order_update_message": "Your order has been updated", "@_comment_notification_details": "Notification details labels", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 54e11537..ee851d6d 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1090,6 +1090,8 @@ "notification_cant_do_message": "La acción solicitada no se puede realizar", "notification_new_message_title": "Nuevo mensaje", "notification_new_message_message": "Has recibido un nuevo mensaje", + "notification_admin_message_title": "Mensaje del administrador", + "notification_admin_message_message": "Has recibido un mensaje del administrador de la disputa", "notification_order_update_title": "Actualización de orden", "notification_order_update_message": "Tu orden ha sido actualizada", "@_comment_notification_details": "Etiquetas de detalles de notificaciones", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 9b90b63e..c663334e 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1176,6 +1176,8 @@ "notification_cant_do_message": "L'action demandée ne peut pas être effectuée", "notification_new_message_title": "Nouveau message", "notification_new_message_message": "Vous avez reçu un nouveau message", + "notification_admin_message_title": "Message de l'administrateur", + "notification_admin_message_message": "Vous avez reçu un message de l'administrateur du litige", "notification_order_update_title": "Mise à jour de commande", "notification_order_update_message": "Votre commande a été mise à jour", "@_comment_notification_details": "Notification details labels", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index fa44782b..bb61f3b7 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1149,6 +1149,8 @@ "notification_cant_do_message": "L'azione richiesta non può essere eseguita", "notification_new_message_title": "Nuovo messaggio", "notification_new_message_message": "Hai ricevuto un nuovo messaggio", + "notification_admin_message_title": "Messaggio dall'amministratore", + "notification_admin_message_message": "Hai ricevuto un messaggio dall'amministratore della disputa", "notification_order_update_title": "Aggiornamento ordine", "notification_order_update_message": "Il tuo ordine è stato aggiornato", "@_comment_notification_details": "Etichette dei dettagli delle notifiche", diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index beeb7c54..a7b524a5 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -14,6 +14,7 @@ import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class MostroService { final Ref ref; @@ -105,6 +106,8 @@ class MostroService { final eventStore = ref.read(eventStorageProvider); if (await eventStore.hasItem(event.id!)) return; + + // Reserve event ID immediately to prevent duplicate processing from multiple relays await eventStore.putItem(event.id!, { 'id': event.id, 'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000, @@ -133,6 +136,13 @@ class MostroService { return; } + // Skip dispute chat DMs — DisputeChatNotifier handles these + // via its own adminSharedKey subscription + if (NostrUtils.isDmPayload(result[0])) { + logger.i('Skipping dispute chat message (handled by DisputeChatNotifier)'); + return; + } + // Skip restore-specific payloads that arrive as historical events due to temporary subscription if (result[0] is Map && _isRestorePayload(result[0] as Map)) { diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 41d983f1..a0e3e8ca 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -444,6 +444,19 @@ class NostrUtils { } } + /// Checks if a decoded Mostro payload item is a DM (dispute/admin chat) message. + /// DM messages use the format: {"dm": {"action": "send-dm", "payload": {...}}} + /// Validates shape strictly to avoid false positives from other payloads. + static bool isDmPayload(dynamic item) { + if (item is! Map) return false; + final dm = item['dm']; + if (dm is! Map) return false; + if (dm['action'] != 'send-dm') return false; + final payload = dm['payload']; + if (payload is! Map) return false; + return true; + } + static Future encryptNIP44( String content, String privkey, diff --git a/test/features/notifications/services/background_notification_dm_detection_test.dart b/test/features/notifications/services/background_notification_dm_detection_test.dart new file mode 100644 index 00000000..253af5e8 --- /dev/null +++ b/test/features/notifications/services/background_notification_dm_detection_test.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart'; +import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +/// Tests for the admin/dispute DM background notification pipeline (Phase 1). +/// +/// Validates three layers: +/// 1. [NostrUtils.isDmPayload] — pure detection of the `{"dm": ...}` envelope +/// 2. [MostroMessage] construction — synthetic message with [Action.sendDm] +/// 3. [NotificationDataExtractor] — ensures sendDm is NOT marked temporary +void main() { + group('NostrUtils.isDmPayload', () { + test('detects dm wrapper key in decoded JSON', () { + final dmPayload = jsonDecode( + '[{"dm": {"action": "send-dm", "payload": {"text_message": "Hello"}}}]', + ); + + expect(dmPayload, isList); + expect(dmPayload, isNotEmpty); + expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue); + }); + + test('does not detect dm key in standard Mostro message', () { + final orderPayload = jsonDecode( + '[{"order": {"action": "new-order", "id": "abc123", "payload": null}}]', + ); + + expect(NostrUtils.isDmPayload(orderPayload[0]), isFalse); + }); + + test('does not detect dm key in restore message', () { + final restorePayload = jsonDecode( + '[{"restore": {"action": "restore-session", "id": "abc123"}}]', + ); + + expect(NostrUtils.isDmPayload(restorePayload[0]), isFalse); + }); + + test('does not detect dm key in cant-do message', () { + final cantDoPayload = jsonDecode( + '[{"cant-do": {"action": "cant-do", "payload": null}}]', + ); + + expect(NostrUtils.isDmPayload(cantDoPayload[0]), isFalse); + }); + + test('returns false for non-Map types', () { + expect(NostrUtils.isDmPayload('string'), isFalse); + expect(NostrUtils.isDmPayload(42), isFalse); + expect(NostrUtils.isDmPayload(null), isFalse); + expect(NostrUtils.isDmPayload([]), isFalse); + }); + + test('returns false when dm value is not a Map', () { + expect(NostrUtils.isDmPayload({'dm': 'not-a-map'}), isFalse); + }); + + test('returns false when dm Map has no action', () { + expect(NostrUtils.isDmPayload({'dm': {}}), isFalse); + }); + + test('returns false when dm action is not send-dm', () { + expect( + NostrUtils.isDmPayload({'dm': {'action': 'different-action'}}), + isFalse, + ); + }); + + test('returns false when payload is null', () { + expect( + NostrUtils.isDmPayload({ + 'dm': {'action': 'send-dm', 'payload': null} + }), + isFalse, + ); + }); + + test('returns false when payload is not a Map', () { + expect( + NostrUtils.isDmPayload({ + 'dm': {'action': 'send-dm', 'payload': []} + }), + isFalse, + ); + expect( + NostrUtils.isDmPayload({ + 'dm': {'action': 'send-dm', 'payload': 'string'} + }), + isFalse, + ); + }); + + test('returns false when dm has action but no payload', () { + expect( + NostrUtils.isDmPayload({ + 'dm': {'action': 'send-dm'} + }), + isFalse, + ); + }); + + test('returns true with valid payload structure', () { + expect( + NostrUtils.isDmPayload({ + 'dm': { + 'action': 'send-dm', + 'payload': {'text_message': 'Hello admin'} + } + }), + isTrue, + ); + }); + }); + + group('MostroMessage construction for DM', () { + test('preserves orderId and timestamp with sendDm action', () { + const testOrderId = 'test-order-123'; + const testTimestamp = 1700000000000; + + final message = MostroMessage( + action: Action.sendDm, + id: testOrderId, + timestamp: testTimestamp, + ); + + expect(message.action, Action.sendDm); + expect(message.id, testOrderId); + expect(message.timestamp, testTimestamp); + }); + }); + + group('NotificationDataExtractor for sendDm', () { + test('produces non-temporary notification data', () async { + final message = MostroMessage( + action: Action.sendDm, + id: 'order-abc', + timestamp: 1700000000000, + ); + + final data = await NotificationDataExtractor.extractFromMostroMessage( + message, + null, + ); + + expect(data, isNotNull); + expect(data!.isTemporary, isFalse); + expect(data.action, Action.sendDm); + expect(data.orderId, 'order-abc'); + }); + + test('returns empty values map (no payload extraction)', () async { + final message = MostroMessage( + action: Action.sendDm, + id: 'order-xyz', + ); + + final data = await NotificationDataExtractor.extractFromMostroMessage( + message, + null, + ); + + expect(data, isNotNull); + expect(data!.values, isEmpty); + }); + }); + + group('NotificationDataExtractor for cooperativeCancelAccepted', () { + test('produces non-temporary notification data', () async { + final message = MostroMessage( + action: Action.cooperativeCancelAccepted, + id: 'order-cancel', + ); + + final data = await NotificationDataExtractor.extractFromMostroMessage( + message, + null, + ); + + expect(data, isNotNull); + expect(data!.isTemporary, isFalse); + expect(data.action, Action.cooperativeCancelAccepted); + }); + }); + + group('resolveNotificationRoute', () { + test('navigates to notifications screen when payload is null', () { + expect(resolveNotificationRoute(null), '/notifications'); + }); + + test('navigates to notifications screen when payload is empty', () { + expect(resolveNotificationRoute(''), '/notifications'); + }); + + test('navigates to dispute chat when admin_dm has disputeId', () { + final payload = jsonEncode({ + 'type': 'admin_dm', + 'orderId': 'order-123', + 'disputeId': 'dispute-456', + }); + expect( + resolveNotificationRoute(payload), + '/dispute_details/dispute-456', + ); + }); + + test('falls back to trade detail when admin_dm has no disputeId', () { + final payload = jsonEncode({ + 'type': 'admin_dm', + 'orderId': 'order-123', + }); + expect(resolveNotificationRoute(payload), '/trade_detail/order-123'); + }); + + test('falls back to trade detail when admin_dm orderId is null', () { + final payload = jsonEncode({ + 'type': 'admin_dm', + 'orderId': null, + }); + expect(resolveNotificationRoute(payload), '/trade_detail/$payload'); + }); + + test('treats plain string as legacy orderId payload', () { + expect( + resolveNotificationRoute('legacy-order-id'), + '/trade_detail/legacy-order-id', + ); + }); + + test('handles JSON that is not an object gracefully', () { + expect(resolveNotificationRoute('"just-a-string"'), '/trade_detail/"just-a-string"'); + expect(resolveNotificationRoute('[1,2,3]'), '/trade_detail/[1,2,3]'); + }); + + test('handles unknown type in JSON payload', () { + final payload = jsonEncode({ + 'type': 'unknown_type', + 'orderId': 'order-789', + }); + expect(resolveNotificationRoute(payload), '/trade_detail/$payload'); + }); + }); +}