/ app / test / providers / messages_provider_test.dart
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  }