diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 6900790c..98fd8f30 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -14,6 +14,7 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'package:sembast/sembast.dart'; /// State for dispute chat messages @@ -157,7 +158,7 @@ class DisputeChatNotifier extends StateNotifier { final messageData = contentData[0]; // Check if it's the CLI format with Message enum (has "dm" key) - if (messageData is Map && messageData.containsKey('dm')) { + if (NostrUtils.isDmPayload(messageData)) { final dmData = messageData['dm']; if (dmData is Map && dmData.containsKey('payload')) { final payload = dmData['payload']; diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 5b44a1a4..252d125b 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_en.dart'; @@ -171,6 +172,16 @@ Future _decryptAndProcessEvent(NostrEvent event) async { return null; } + // Detect admin/dispute DM format: [{"dm": {"action": "send-dm", ...}}] + final firstItem = result[0]; + if (NostrUtils.isDmPayload(firstItem)) { + 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; 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/services/mostro_service.dart b/lib/services/mostro_service.dart index 5233ecbd..9e3c80bc 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/shared/providers.dart'; 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/shared/utils/nostr_utils.dart'; class MostroService { final Ref ref; @@ -130,7 +131,7 @@ class MostroService { } // Skip dispute chat messages (they have "dm" key and are handled by DisputeChatNotifier) - if (result[0] is Map && (result[0] as Map).containsKey('dm')) { + if (NostrUtils.isDmPayload(result[0])) { logger.i('Skipping dispute chat message (handled by DisputeChatNotifier)'); return; } diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index bf4aa06e..d80cd742 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -379,6 +379,12 @@ 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", ...}} + static bool isDmPayload(dynamic item) { + return item is Map && item.containsKey('dm'); + } + static Future encryptNIP44( String content, String privkey, String pubkey) async { try { 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..c99f5df1 --- /dev/null +++ b/test/features/notifications/services/background_notification_dm_detection_test.dart @@ -0,0 +1,134 @@ +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/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('handles dm payload with minimal content', () { + final dmPayload = jsonDecode('[{"dm": {}}]'); + + expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue); + }); + + 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); + }); + }); + + 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); + }); + }); +}