messages_provider_test.dart
1 import 'dart:typed_data'; 2 3 import 'package:dead_drop/providers/ble_provider.dart'; 4 import 'package:dead_drop/providers/messages_provider.dart'; 5 import 'package:dead_drop/providers/transport_provider.dart'; 6 import 'package:dead_drop/services/ble/ble_service.dart'; 7 import 'package:dead_drop/src/rust/api.dart' as api; 8 import 'package:flutter_riverpod/flutter_riverpod.dart'; 9 import 'package:flutter_test/flutter_test.dart'; 10 import 'package:mocktail/mocktail.dart'; 11 12 import '../fixtures/test_fixtures.dart'; 13 import '../helpers/test_helpers.dart'; 14 import '../mocks/mock_dead_drop_service.dart'; 15 16 // --------------------------------------------------------------------------- 17 // Test helpers for transport state provider overrides 18 // --------------------------------------------------------------------------- 19 20 /// A DhtStateNotifier that skips the FFI call in its constructor. 21 class _TestDhtStateNotifier extends DhtStateNotifier { 22 _TestDhtStateNotifier(DhtState initial) : super() { 23 // The parent constructor calls _refresh() which hits FFI and may throw, 24 // but we immediately overwrite the state here. 25 state = initial; 26 } 27 } 28 29 /// A TransportPreferenceNotifier that skips FFI. 30 class _TestTransportPreferenceNotifier extends TransportPreferenceNotifier { 31 _TestTransportPreferenceNotifier(api.NetworkTransportPreference initial) 32 : super() { 33 state = initial; 34 } 35 } 36 37 /// Minimal mock for BleService so we can control getSessionByContactId. 38 class MockBleService extends Mock implements BleService {} 39 40 void main() { 41 late MockDeadDropService mockService; 42 late ProviderContainer container; 43 44 final testContactId = TestFixtures.contactId(1); 45 final testContactHex = bytesToHex(testContactId); 46 47 setUpAll(() { 48 registerFallbackValue(Uint8List(16)); 49 }); 50 51 setUp(() { 52 mockService = MockDeadDropService(); 53 }); 54 55 tearDown(() { 56 container.dispose(); 57 }); 58 59 group('MessagesNotifier', () { 60 test('build() returns empty list when not initialized', () async { 61 final uninitService = _UninitializedService(); 62 63 container = createTestContainer(mockService: uninitService); 64 final result = 65 await container.read(messagesProvider(testContactHex).future); 66 67 expect(result, isEmpty); 68 }); 69 70 test('build() returns messages from service', () async { 71 final msg1 = TestFixtures.messageInfo( 72 messageIdBytes: TestFixtures.messageId(1), 73 contactIdBytes: testContactId, 74 ); 75 final msg2 = TestFixtures.messageInfo( 76 messageIdBytes: TestFixtures.messageId(2), 77 contactIdBytes: testContactId, 78 ); 79 80 when(() => mockService.getConversation(any())) 81 .thenAnswer((_) async => [msg1, msg2]); 82 83 container = createTestContainer(mockService: mockService); 84 final result = 85 await container.read(messagesProvider(testContactHex).future); 86 87 expect(result, hasLength(2)); 88 }); 89 90 test('build() filters out profile picture sync messages', () async { 91 final normalMsg = TestFixtures.messageInfo( 92 messageIdBytes: TestFixtures.messageId(1), 93 contactIdBytes: testContactId, 94 ); 95 final profilePicMsg = TestFixtures.messageInfo( 96 messageIdBytes: TestFixtures.messageId(2), 97 contactIdBytes: testContactId, 98 filename: '__profile_picture__avatar.png', 99 ); 100 final profileRemoveMsg = TestFixtures.messageInfo( 101 messageIdBytes: TestFixtures.messageId(3), 102 contactIdBytes: testContactId, 103 filename: '__profile_picture_remove__', 104 ); 105 final normalFileMsg = TestFixtures.messageInfo( 106 messageIdBytes: TestFixtures.messageId(4), 107 contactIdBytes: testContactId, 108 filename: 'photo.jpg', 109 ); 110 111 when(() => mockService.getConversation(any())).thenAnswer( 112 (_) async => [normalMsg, profilePicMsg, profileRemoveMsg, normalFileMsg]); 113 114 container = createTestContainer(mockService: mockService); 115 final result = 116 await container.read(messagesProvider(testContactHex).future); 117 118 expect(result, hasLength(2)); 119 expect(result[0].filename, isNull); 120 expect(result[1].filename, equals('photo.jpg')); 121 }); 122 123 test('markRead() calls service and refreshes state', () async { 124 final msg = TestFixtures.messageInfo( 125 messageIdBytes: TestFixtures.messageId(1), 126 contactIdBytes: testContactId, 127 isOutbound: false, 128 state: 1, 129 ); 130 131 when(() => mockService.getConversation(any())) 132 .thenAnswer((_) async => [msg]); 133 when(() => mockService.markMessageRead(any())).thenReturn(null); 134 135 container = createTestContainer(mockService: mockService); 136 // Initial build 137 await container.read(messagesProvider(testContactHex).future); 138 139 // Mark read — service returns updated message 140 final readMsg = TestFixtures.messageInfo( 141 messageIdBytes: TestFixtures.messageId(1), 142 contactIdBytes: testContactId, 143 isOutbound: false, 144 state: 2, 145 ); 146 when(() => mockService.getConversation(any())) 147 .thenAnswer((_) async => [readMsg]); 148 149 await container 150 .read(messagesProvider(testContactHex).notifier) 151 .markRead(msg.messageId); 152 153 verify(() => mockService.markMessageRead(any())).called(1); 154 155 final result = 156 await container.read(messagesProvider(testContactHex).future); 157 expect(result.first.state, equals(2)); 158 }); 159 160 test('deleteMessage() calls service and refreshes state', () async { 161 final msg = TestFixtures.messageInfo( 162 messageIdBytes: TestFixtures.messageId(1), 163 contactIdBytes: testContactId, 164 ); 165 166 when(() => mockService.getConversation(any())) 167 .thenAnswer((_) async => [msg]); 168 when(() => mockService.deleteMessage(any())) 169 .thenAnswer((_) async {}); 170 171 container = createTestContainer(mockService: mockService); 172 await container.read(messagesProvider(testContactHex).future); 173 174 // After delete, conversation is empty 175 when(() => mockService.getConversation(any())) 176 .thenAnswer((_) async => []); 177 178 await container 179 .read(messagesProvider(testContactHex).notifier) 180 .deleteMessage(msg.messageId); 181 182 verify(() => mockService.deleteMessage(any())).called(1); 183 184 final result = 185 await container.read(messagesProvider(testContactHex).future); 186 expect(result, isEmpty); 187 }); 188 189 test('clearConversation() calls service and refreshes state', () async { 190 final msg = TestFixtures.messageInfo( 191 contactIdBytes: testContactId, 192 ); 193 194 when(() => mockService.getConversation(any())) 195 .thenAnswer((_) async => [msg]); 196 when(() => mockService.clearConversation(any())) 197 .thenAnswer((_) async => 0); 198 199 container = createTestContainer(mockService: mockService); 200 await container.read(messagesProvider(testContactHex).future); 201 202 // After clear, conversation is empty 203 when(() => mockService.getConversation(any())) 204 .thenAnswer((_) async => []); 205 206 await container 207 .read(messagesProvider(testContactHex).notifier) 208 .clearConversation(); 209 210 verify(() => mockService.clearConversation(any())).called(1); 211 212 final result = 213 await container.read(messagesProvider(testContactHex).future); 214 expect(result, isEmpty); 215 }); 216 }); 217 218 group('MessagesNotifier delivery chain', () { 219 /// Helper: set up common overrides for delivery chain tests. 220 /// 221 /// Overrides DHT state and transport preference (whose real notifiers 222 /// call FFI in constructors). Tor and iroh notifiers are safe to 223 /// instantiate without FFI — they default to disconnected state. 224 List<Override> deliveryOverrides({ 225 DhtState dhtState = const DhtState(state: 'disconnected'), 226 api.NetworkTransportPreference pref = 227 api.NetworkTransportPreference.dhtPreferred, 228 MockBleService? bleService, 229 }) { 230 return [ 231 dhtStateProvider.overrideWith( 232 (ref) => _TestDhtStateNotifier(dhtState)), 233 transportPreferenceProvider.overrideWith( 234 (ref) => _TestTransportPreferenceNotifier(pref)), 235 if (bleService != null) 236 bleServiceProvider.overrideWithValue(bleService), 237 ]; 238 } 239 240 test('sendText() with DHT connected publishes to DHT', () async { 241 when(() => mockService.getConversation(any())) 242 .thenAnswer((_) async => []); 243 when(() => mockService.sendTextMessage(any(), any())) 244 .thenAnswer((_) async => TestFixtures.messageId(1)); 245 when(() => mockService.requeueFailedMessages(any())) 246 .thenAnswer((_) async => 0); 247 when(() => mockService.publishToDht(any())) 248 .thenAnswer((_) async => 1); 249 250 container = createTestContainer( 251 mockService: mockService, 252 overrides: deliveryOverrides( 253 dhtState: const DhtState(state: 'connected', peerCount: 5), 254 pref: api.NetworkTransportPreference.dhtPreferred, 255 ), 256 ); 257 258 await container.read(messagesProvider(testContactHex).future); 259 260 await container 261 .read(messagesProvider(testContactHex).notifier) 262 .sendText('Hello'); 263 264 verify(() => mockService.publishToDht(any())).called(1); 265 }); 266 267 test('sendText() skips DHT when relayOnly preference', () async { 268 when(() => mockService.getConversation(any())) 269 .thenAnswer((_) async => []); 270 when(() => mockService.sendTextMessage(any(), any())) 271 .thenAnswer((_) async => TestFixtures.messageId(1)); 272 when(() => mockService.requeueFailedMessages(any())) 273 .thenAnswer((_) async => 0); 274 275 container = createTestContainer( 276 mockService: mockService, 277 overrides: deliveryOverrides( 278 dhtState: const DhtState(state: 'connected', peerCount: 5), 279 pref: api.NetworkTransportPreference.relayOnly, 280 ), 281 ); 282 283 await container.read(messagesProvider(testContactHex).future); 284 285 await container 286 .read(messagesProvider(testContactHex).notifier) 287 .sendText('Hello'); 288 289 verifyNever(() => mockService.publishToDht(any())); 290 }); 291 292 test('sendText() with active BLE session skips network delivery', 293 () async { 294 when(() => mockService.getConversation(any())) 295 .thenAnswer((_) async => []); 296 when(() => mockService.sendTextMessage(any(), any())) 297 .thenAnswer((_) async => TestFixtures.messageId(1)); 298 299 final mockBle = MockBleService(); 300 when(() => mockBle.getSessionByContactId(any())) 301 .thenReturn(BleSession(deviceId: 'test-device', isInitiator: false)); 302 303 container = createTestContainer( 304 mockService: mockService, 305 overrides: deliveryOverrides( 306 dhtState: const DhtState(state: 'connected', peerCount: 5), 307 bleService: mockBle, 308 ), 309 ); 310 311 await container.read(messagesProvider(testContactHex).future); 312 313 await container 314 .read(messagesProvider(testContactHex).notifier) 315 .sendText('Hello'); 316 317 // BLE is active — network delivery chain should not run 318 verifyNever(() => mockService.requeueFailedMessages(any())); 319 verifyNever(() => mockService.publishToDht(any())); 320 }); 321 322 test('retryDelivery() re-queues failed then runs delivery chain', 323 () async { 324 when(() => mockService.getConversation(any())) 325 .thenAnswer((_) async => []); 326 when(() => mockService.requeueFailedMessages(any())) 327 .thenAnswer((_) async => 2); 328 when(() => mockService.publishToDht(any())) 329 .thenAnswer((_) async => 1); 330 331 container = createTestContainer( 332 mockService: mockService, 333 overrides: deliveryOverrides( 334 dhtState: const DhtState(state: 'connected', peerCount: 5), 335 ), 336 ); 337 338 await container.read(messagesProvider(testContactHex).future); 339 340 await container 341 .read(messagesProvider(testContactHex).notifier) 342 .retryDelivery(); 343 344 verify(() => mockService.requeueFailedMessages(any())).called(1); 345 verify(() => mockService.publishToDht(any())).called(1); 346 }); 347 }); 348 349 group('contactUnreadCountProvider', () { 350 test('returns 0 when no messages', () async { 351 when(() => mockService.getConversation(any())) 352 .thenAnswer((_) async => []); 353 354 container = createTestContainer(mockService: mockService); 355 // Must await the messages provider first so it's populated 356 await container.read(messagesProvider(testContactHex).future); 357 358 final count = container.read(contactUnreadCountProvider(testContactHex)); 359 expect(count, equals(0)); 360 }); 361 362 test('counts only inbound unread messages (state == 1, isOutbound == false)', 363 () async { 364 final unreadInbound = TestFixtures.messageInfo( 365 messageIdBytes: TestFixtures.messageId(1), 366 contactIdBytes: testContactId, 367 isOutbound: false, 368 state: 1, 369 ); 370 final readInbound = TestFixtures.messageInfo( 371 messageIdBytes: TestFixtures.messageId(2), 372 contactIdBytes: testContactId, 373 isOutbound: false, 374 state: 2, 375 ); 376 final unreadInbound2 = TestFixtures.messageInfo( 377 messageIdBytes: TestFixtures.messageId(3), 378 contactIdBytes: testContactId, 379 isOutbound: false, 380 state: 1, 381 ); 382 383 when(() => mockService.getConversation(any())) 384 .thenAnswer((_) async => [unreadInbound, readInbound, unreadInbound2]); 385 386 container = createTestContainer(mockService: mockService); 387 await container.read(messagesProvider(testContactHex).future); 388 389 final count = container.read(contactUnreadCountProvider(testContactHex)); 390 expect(count, equals(2)); 391 }); 392 393 test('ignores outbound messages', () async { 394 final outboundUnread = TestFixtures.messageInfo( 395 messageIdBytes: TestFixtures.messageId(1), 396 contactIdBytes: testContactId, 397 isOutbound: true, 398 state: 1, 399 ); 400 final inboundUnread = TestFixtures.messageInfo( 401 messageIdBytes: TestFixtures.messageId(2), 402 contactIdBytes: testContactId, 403 isOutbound: false, 404 state: 1, 405 ); 406 407 when(() => mockService.getConversation(any())) 408 .thenAnswer((_) async => [outboundUnread, inboundUnread]); 409 410 container = createTestContainer(mockService: mockService); 411 await container.read(messagesProvider(testContactHex).future); 412 413 final count = container.read(contactUnreadCountProvider(testContactHex)); 414 expect(count, equals(1)); 415 }); 416 }); 417 } 418 419 class _UninitializedService extends MockDeadDropService { 420 @override 421 bool get isInitialized => false; 422 }