From ac4d2516796c625cc3cad389d502c70bd46216b8 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 24 Feb 2026 14:00:55 -0300 Subject: [PATCH 1/3] feat: detect admin/dispute DM format in background notification service Add detection for admin/dispute DM messages in background service by checking for {"dm": {...}} format before standard MostroMessage parsing. Return synthetic MostroMessage with sendDm action to trigger notification flow. Part of chat notifications implementation (Phase 1: Admin DM background notifications). --- .../background_notification_service.dart | 10 ++ ...ground_notification_dm_detection_test.dart | 102 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 test/features/notifications/services/background_notification_dm_detection_test.dart diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 5b44a1a4..1adff7d4 100644 --- a/lib/features/notifications/services/background_notification_service.dart +++ b/lib/features/notifications/services/background_notification_service.dart @@ -171,6 +171,16 @@ Future _decryptAndProcessEvent(NostrEvent event) async { return null; } + // Detect admin/dispute DM format: [{"dm": {"action": "send-dm", ...}}] + final firstItem = result[0]; + if (firstItem is Map && firstItem.containsKey('dm')) { + 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/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..8d1dd437 --- /dev/null +++ b/test/features/notifications/services/background_notification_dm_detection_test.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; + +/// Tests for DM format detection logic used in background notification service. +/// +/// The background service's `_decryptAndProcessEvent()` must distinguish between: +/// - Standard Mostro messages: [{"order": {"action": "...", ...}}] +/// - Admin/dispute DM messages: [{"dm": {"action": "send-dm", ...}}] +/// +/// Since the actual method is a private top-level function that depends on +/// NostrEvent decryption and database access, we test the detection logic +/// and MostroMessage construction in isolation. +void main() { + group('Admin DM format detection', () { + 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); + + final firstItem = dmPayload[0]; + expect(firstItem, isA()); + expect((firstItem as Map).containsKey('dm'), isTrue); + }); + + test('does not detect dm key in standard Mostro message', () { + final orderPayload = jsonDecode( + '[{"order": {"action": "new-order", "id": "abc123", "payload": null}}]', + ); + + final firstItem = orderPayload[0]; + expect(firstItem, isA()); + expect((firstItem as Map).containsKey('dm'), isFalse); + expect((firstItem as Map).containsKey('order'), isTrue); + }); + + test('does not detect dm key in restore message', () { + final restorePayload = jsonDecode( + '[{"restore": {"action": "restore-session", "id": "abc123"}}]', + ); + + final firstItem = restorePayload[0]; + expect((firstItem as Map).containsKey('dm'), isFalse); + }); + + test('does not detect dm key in cant-do message', () { + final cantDoPayload = jsonDecode( + '[{"cant-do": {"action": "cant-do", "payload": null}}]', + ); + + final firstItem = cantDoPayload[0]; + expect((firstItem as Map).containsKey('dm'), isFalse); + }); + + test('MostroMessage with sendDm action preserves orderId and timestamp', () { + const testOrderId = 'test-order-123'; + const testTimestamp = 1700000000000; + + // This mirrors what _decryptAndProcessEvent creates for DM detection + final message = _createDmNotificationMessage(testOrderId, testTimestamp); + + expect(message.action, equals(Action.sendDm)); + expect(message.id, equals(testOrderId)); + expect(message.timestamp, equals(testTimestamp)); + }); + + test('handles dm payload with minimal content', () { + final dmPayload = jsonDecode('[{"dm": {}}]'); + + final firstItem = dmPayload[0]; + expect((firstItem as Map).containsKey('dm'), isTrue); + }); + }); +} + +/// Simulates the MostroMessage construction from _decryptAndProcessEvent +/// when a DM format is detected. +_DmMessage _createDmNotificationMessage(String orderId, int timestamp) { + return _DmMessage( + action: Action.sendDm, + id: orderId, + timestamp: timestamp, + ); +} + +/// Lightweight wrapper to test the construction without importing MostroMessage +/// (which requires full Payload generics setup). +class _DmMessage { + final Action action; + final String id; + final int timestamp; + + _DmMessage({ + required this.action, + required this.id, + required this.timestamp, + }); +} From fda96a611588dfa304dad2d9f76aec98d8df3788 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 24 Feb 2026 14:44:45 -0300 Subject: [PATCH 2/3] refactor: extract DM payload detection into shared NostrUtils.isDmPayload Extract duplicate DM format detection logic (`item is Map && item.containsKey('dm')`) into shared `NostrUtils.isDmPayload()` method. Replace inline checks in DisputeChatNotifier, BackgroundNotificationService, and MostroService with calls to the new utility. Update tests to exercise NostrUtils.isDmPayload directly instead of testing detection logic in isolation. Add edge case coverage for non-Map types. --- .../notifiers/dispute_chat_notifier.dart | 3 +- .../background_notification_service.dart | 3 +- lib/services/mostro_service.dart | 3 +- lib/shared/utils/nostr_utils.dart | 6 +++ ...ground_notification_dm_detection_test.dart | 49 ++++++++----------- 5 files changed, 33 insertions(+), 31 deletions(-) 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 1adff7d4..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'; @@ -173,7 +174,7 @@ Future _decryptAndProcessEvent(NostrEvent event) async { // Detect admin/dispute DM format: [{"dm": {"action": "send-dm", ...}}] final firstItem = result[0]; - if (firstItem is Map && firstItem.containsKey('dm')) { + if (NostrUtils.isDmPayload(firstItem)) { return MostroMessage( action: mostro_action.Action.sendDm, id: matchingSession.orderId, 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 index 8d1dd437..6f6d72fc 100644 --- a/test/features/notifications/services/background_notification_dm_detection_test.dart +++ b/test/features/notifications/services/background_notification_dm_detection_test.dart @@ -2,18 +2,14 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; /// Tests for DM format detection logic used in background notification service. /// -/// The background service's `_decryptAndProcessEvent()` must distinguish between: -/// - Standard Mostro messages: [{"order": {"action": "...", ...}}] -/// - Admin/dispute DM messages: [{"dm": {"action": "send-dm", ...}}] -/// -/// Since the actual method is a private top-level function that depends on -/// NostrEvent decryption and database access, we test the detection logic -/// and MostroMessage construction in isolation. +/// Tests exercise [NostrUtils.isDmPayload], the same pure function called by +/// `_decryptAndProcessEvent()`, `MostroService`, and `DisputeChatNotifier`. void main() { - group('Admin DM format detection', () { + group('NostrUtils.isDmPayload', () { test('detects dm wrapper key in decoded JSON', () { final dmPayload = jsonDecode( '[{"dm": {"action": "send-dm", "payload": {"text_message": "Hello"}}}]', @@ -21,10 +17,7 @@ void main() { expect(dmPayload, isList); expect(dmPayload, isNotEmpty); - - final firstItem = dmPayload[0]; - expect(firstItem, isA()); - expect((firstItem as Map).containsKey('dm'), isTrue); + expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue); }); test('does not detect dm key in standard Mostro message', () { @@ -32,10 +25,7 @@ void main() { '[{"order": {"action": "new-order", "id": "abc123", "payload": null}}]', ); - final firstItem = orderPayload[0]; - expect(firstItem, isA()); - expect((firstItem as Map).containsKey('dm'), isFalse); - expect((firstItem as Map).containsKey('order'), isTrue); + expect(NostrUtils.isDmPayload(orderPayload[0]), isFalse); }); test('does not detect dm key in restore message', () { @@ -43,8 +33,7 @@ void main() { '[{"restore": {"action": "restore-session", "id": "abc123"}}]', ); - final firstItem = restorePayload[0]; - expect((firstItem as Map).containsKey('dm'), isFalse); + expect(NostrUtils.isDmPayload(restorePayload[0]), isFalse); }); test('does not detect dm key in cant-do message', () { @@ -52,28 +41,32 @@ void main() { '[{"cant-do": {"action": "cant-do", "payload": null}}]', ); - final firstItem = cantDoPayload[0]; - expect((firstItem as Map).containsKey('dm'), isFalse); + 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); }); test('MostroMessage with sendDm action preserves orderId and timestamp', () { const testOrderId = 'test-order-123'; const testTimestamp = 1700000000000; - // This mirrors what _decryptAndProcessEvent creates for DM detection final message = _createDmNotificationMessage(testOrderId, testTimestamp); expect(message.action, equals(Action.sendDm)); expect(message.id, equals(testOrderId)); expect(message.timestamp, equals(testTimestamp)); }); - - test('handles dm payload with minimal content', () { - final dmPayload = jsonDecode('[{"dm": {}}]'); - - final firstItem = dmPayload[0]; - expect((firstItem as Map).containsKey('dm'), isTrue); - }); }); } From a0720194fc65e2c4d5073dbf8f8c275fc33a5668 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 24 Feb 2026 14:57:14 -0300 Subject: [PATCH 3/3] feat: add sendDm and cooperativeCancelAccepted to notification data extractor Add explicit case handlers for Action.sendDm and Action.cooperativeCancelAccepted in NotificationDataExtractor to ensure they generate non-temporary notifications. Both actions require no payload extraction (empty values map). Expand test coverage to validate three layers of the admin/dispute DM notification pipeline: NostrUtils.isDmPayload detection, MostroMessage construction with sendDm action, and NotificationDataExtractor --- .../utils/notification_data_extractor.dart | 14 ++- ...ground_notification_dm_detection_test.dart | 97 +++++++++++++------ 2 files changed, 79 insertions(+), 32 deletions(-) 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/test/features/notifications/services/background_notification_dm_detection_test.dart b/test/features/notifications/services/background_notification_dm_detection_test.dart index 6f6d72fc..c99f5df1 100644 --- a/test/features/notifications/services/background_notification_dm_detection_test.dart +++ b/test/features/notifications/services/background_notification_dm_detection_test.dart @@ -2,12 +2,16 @@ 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 DM format detection logic used in background notification service. +/// Tests for the admin/dispute DM background notification pipeline (Phase 1). /// -/// Tests exercise [NostrUtils.isDmPayload], the same pure function called by -/// `_decryptAndProcessEvent()`, `MostroService`, and `DisputeChatNotifier`. +/// 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', () { @@ -56,40 +60,75 @@ void main() { expect(NostrUtils.isDmPayload(null), isFalse); expect(NostrUtils.isDmPayload([]), isFalse); }); + }); - test('MostroMessage with sendDm action preserves orderId and timestamp', () { + group('MostroMessage construction for DM', () { + test('preserves orderId and timestamp with sendDm action', () { const testOrderId = 'test-order-123'; const testTimestamp = 1700000000000; - final message = _createDmNotificationMessage(testOrderId, testTimestamp); + final message = MostroMessage( + action: Action.sendDm, + id: testOrderId, + timestamp: testTimestamp, + ); - expect(message.action, equals(Action.sendDm)); - expect(message.id, equals(testOrderId)); - expect(message.timestamp, equals(testTimestamp)); + expect(message.action, Action.sendDm); + expect(message.id, testOrderId); + expect(message.timestamp, testTimestamp); }); }); -} -/// Simulates the MostroMessage construction from _decryptAndProcessEvent -/// when a DM format is detected. -_DmMessage _createDmNotificationMessage(String orderId, int timestamp) { - return _DmMessage( - action: Action.sendDm, - id: orderId, - timestamp: timestamp, - ); -} + 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', + ); -/// Lightweight wrapper to test the construction without importing MostroMessage -/// (which requires full Payload generics setup). -class _DmMessage { - final Action action; - final String id; - final int timestamp; - - _DmMessage({ - required this.action, - required this.id, - required this.timestamp, + 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); + }); }); }