import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:unionflow_mobile_apps/features/communication/presentation/bloc/messaging_bloc.dart'; import 'package:unionflow_mobile_apps/features/communication/presentation/bloc/messaging_event.dart'; import 'package:unionflow_mobile_apps/features/communication/presentation/bloc/messaging_state.dart'; import 'package:unionflow_mobile_apps/features/communication/domain/repositories/messaging_repository.dart'; import 'package:unionflow_mobile_apps/features/communication/domain/entities/conversation.dart'; import 'package:unionflow_mobile_apps/features/communication/domain/entities/message.dart'; import 'package:unionflow_mobile_apps/core/websocket/websocket_service.dart'; @GenerateMocks([MessagingRepository, WebSocketService]) import 'messaging_bloc_test.mocks.dart'; void main() { late MessagingBloc bloc; late MockMessagingRepository mockRepository; late MockWebSocketService mockWebSocketService; late StreamController wsController; // ── Fixtures ────────────────────────────────────────────────────────────── ConversationSummary fakeConversationSummary({ String id = 'conv-1', String type = 'DIRECTE', }) => ConversationSummary( id: id, typeConversation: type, titre: 'Conversation $id', statut: 'ACTIVE', nonLus: 0, ); Conversation fakeConversation({String id = 'conv-1'}) => Conversation( id: id, typeConversation: 'DIRECTE', titre: 'Conversation $id', statut: 'ACTIVE', messages: [], participants: [], ); Message fakeMessage({String id = 'msg-1'}) => Message( id: id, typeMessage: 'TEXTE', contenu: 'Bonjour !', dateEnvoi: DateTime.now(), ); setUp(() { wsController = StreamController.broadcast(); mockRepository = MockMessagingRepository(); mockWebSocketService = MockWebSocketService(); when(mockWebSocketService.eventStream).thenAnswer((_) => wsController.stream); bloc = MessagingBloc( repository: mockRepository, webSocketService: mockWebSocketService, ); }); tearDown(() async { await bloc.close(); await wsController.close(); }); // ── Initial state ───────────────────────────────────────────────────────── test('initial state is MessagingInitial', () { expect(bloc.state, isA()); }); // ── LoadMesConversations ────────────────────────────────────────────────── group('LoadMesConversations', () { blocTest( 'emits [MessagingLoading, MesConversationsLoaded] on success', build: () { when(mockRepository.getMesConversations()).thenAnswer( (_) async => [ fakeConversationSummary(), fakeConversationSummary(id: 'conv-2', type: 'ROLE_CANAL'), ], ); return bloc; }, act: (b) => b.add(const LoadMesConversations()), expect: () => [ isA(), isA().having( (s) => s.conversations.length, 'conversations.length', 2, ), ], verify: (_) => verify(mockRepository.getMesConversations()).called(1), ); blocTest( 'emits [MessagingLoading, MesConversationsLoaded] with empty list', build: () { when(mockRepository.getMesConversations()).thenAnswer((_) async => []); return bloc; }, act: (b) => b.add(const LoadMesConversations()), expect: () => [ isA(), isA().having( (s) => s.conversations, 'conversations', isEmpty, ), ], ); blocTest( 'emits [MessagingLoading, MessagingError] on failure', build: () { when(mockRepository.getMesConversations()) .thenThrow(Exception('network error')); return bloc; }, act: (b) => b.add(const LoadMesConversations()), expect: () => [ isA(), isA(), ], ); }); // ── OpenConversation ────────────────────────────────────────────────────── group('OpenConversation', () { blocTest( 'emits [MessagingLoading, ConversationOuverte] on success', build: () { when(mockRepository.getConversation('conv-1')) .thenAnswer((_) async => fakeConversation()); when(mockRepository.marquerLu('conv-1')).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const OpenConversation('conv-1')), expect: () => [ isA(), isA().having( (s) => s.conversation.id, 'conversation.id', 'conv-1', ), ], verify: (_) { verify(mockRepository.getConversation('conv-1')).called(1); }, ); blocTest( 'emits [MessagingLoading, MessagingError] when conversation not found', build: () { when(mockRepository.getConversation(any)) .thenThrow(Exception('Conversation introuvable')); return bloc; }, act: (b) => b.add(const OpenConversation('conv-x')), expect: () => [isA(), isA()], ); }); // ── DemarrerConversationDirecte ─────────────────────────────────────────── group('DemarrerConversationDirecte', () { blocTest( 'emits [MessagingLoading, ConversationCreee] on success', build: () { when(mockRepository.demarrerConversationDirecte( destinataireId: 'usr-2', organisationId: 'org-1', premierMessage: anyNamed('premierMessage'), )).thenAnswer((_) async => fakeConversation(id: 'conv-new')); return bloc; }, act: (b) => b.add(const DemarrerConversationDirecte( destinataireId: 'usr-2', organisationId: 'org-1', premierMessage: 'Bonjour !', )), expect: () => [ isA(), isA().having( (s) => s.conversation.id, 'conversation.id', 'conv-new', ), ], ); blocTest( 'emits [MessagingLoading, MessagingError] on failure', build: () { when(mockRepository.demarrerConversationDirecte( destinataireId: anyNamed('destinataireId'), organisationId: anyNamed('organisationId'), premierMessage: anyNamed('premierMessage'), )).thenThrow(Exception('Cannot start conversation')); return bloc; }, act: (b) => b.add(const DemarrerConversationDirecte( destinataireId: 'usr-2', organisationId: 'org-1', )), expect: () => [isA(), isA()], ); }); // ── DemarrerConversationRole ────────────────────────────────────────────── group('DemarrerConversationRole', () { blocTest( 'emits [MessagingLoading, ConversationCreee] on success', build: () { when(mockRepository.demarrerConversationRole( roleCible: 'TRESORIER', organisationId: 'org-1', premierMessage: anyNamed('premierMessage'), )).thenAnswer((_) async => fakeConversation(id: 'conv-role')); return bloc; }, act: (b) => b.add(const DemarrerConversationRole( roleCible: 'TRESORIER', organisationId: 'org-1', )), expect: () => [ isA(), isA().having( (s) => s.conversation.id, 'conversation.id', 'conv-role', ), ], ); blocTest( 'emits [MessagingLoading, MessagingError] on failure', build: () { when(mockRepository.demarrerConversationRole( roleCible: anyNamed('roleCible'), organisationId: anyNamed('organisationId'), premierMessage: anyNamed('premierMessage'), )).thenThrow(Exception('role not found')); return bloc; }, act: (b) => b.add(const DemarrerConversationRole( roleCible: 'ADMIN', organisationId: 'org-1', )), expect: () => [isA(), isA()], ); }); // ── ArchiverConversation ────────────────────────────────────────────────── group('ArchiverConversation', () { blocTest( 'emits [MessagingActionOk, MessagingLoading, MesConversationsLoaded] on success', build: () { when(mockRepository.archiverConversation('conv-1')) .thenAnswer((_) async {}); when(mockRepository.getMesConversations()).thenAnswer((_) async => []); return bloc; }, act: (b) => b.add(const ArchiverConversation('conv-1')), expect: () => [ isA().having( (s) => s.action, 'action', 'archiver', ), isA(), isA(), ], verify: (_) { verify(mockRepository.archiverConversation('conv-1')).called(1); verify(mockRepository.getMesConversations()).called(1); }, ); blocTest( 'emits [MessagingError] on archive failure', build: () { when(mockRepository.archiverConversation(any)) .thenThrow(Exception('archive failed')); return bloc; }, act: (b) => b.add(const ArchiverConversation('conv-1')), expect: () => [isA()], ); }); // ── EnvoyerMessageTexte ─────────────────────────────────────────────────── group('EnvoyerMessageTexte', () { blocTest( 'emits [MessageEnvoye, MessagesLoaded] on success', build: () { when(mockRepository.envoyerMessage( 'conv-1', typeMessage: 'TEXTE', contenu: 'Hello', messageParentId: anyNamed('messageParentId'), )).thenAnswer((_) async => fakeMessage()); when(mockRepository.getMessages('conv-1', page: anyNamed('page'))) .thenAnswer((_) async => [fakeMessage()]); return bloc; }, act: (b) => b.add(const EnvoyerMessageTexte( conversationId: 'conv-1', contenu: 'Hello', )), expect: () => [ isA() .having((s) => s.conversationId, 'conversationId', 'conv-1') .having((s) => s.message.id, 'message.id', 'msg-1'), isA(), ], ); blocTest( 'emits [MessagingError] on send failure', build: () { when(mockRepository.envoyerMessage( any, typeMessage: anyNamed('typeMessage'), contenu: anyNamed('contenu'), messageParentId: anyNamed('messageParentId'), )).thenThrow(Exception('send failed')); return bloc; }, act: (b) => b.add(const EnvoyerMessageTexte( conversationId: 'conv-1', contenu: 'Hello', )), expect: () => [isA()], ); }); // ── LoadMessages ────────────────────────────────────────────────────────── group('LoadMessages', () { blocTest( 'emits [MessagesLoaded] with hasMore=true when 20 messages returned', build: () { final messages = List.generate( 20, (i) => fakeMessage(id: 'msg-$i'), ); when(mockRepository.getMessages('conv-1', page: 0)) .thenAnswer((_) async => messages); return bloc; }, act: (b) => b.add(const LoadMessages(conversationId: 'conv-1')), expect: () => [ isA() .having((s) => s.messages.length, 'messages.length', 20) .having((s) => s.hasMore, 'hasMore', true) .having((s) => s.conversationId, 'conversationId', 'conv-1'), ], ); blocTest( 'emits [MessagesLoaded] with hasMore=false when less than 20 messages', build: () { when(mockRepository.getMessages('conv-1', page: 0)) .thenAnswer((_) async => [fakeMessage(), fakeMessage(id: 'msg-2')]); return bloc; }, act: (b) => b.add(const LoadMessages(conversationId: 'conv-1')), expect: () => [ isA().having((s) => s.hasMore, 'hasMore', false), ], ); blocTest( 'emits [MessagingError] on load messages failure', build: () { when(mockRepository.getMessages(any, page: anyNamed('page'))) .thenThrow(Exception('load error')); return bloc; }, act: (b) => b.add(const LoadMessages(conversationId: 'conv-1')), expect: () => [isA()], ); }); // ── SupprimerMessage ────────────────────────────────────────────────────── group('SupprimerMessage', () { blocTest( 'emits [MessagingActionOk, MessagesLoaded] on delete success', build: () { when(mockRepository.supprimerMessage('conv-1', 'msg-1')) .thenAnswer((_) async {}); when(mockRepository.getMessages('conv-1', page: anyNamed('page'))) .thenAnswer((_) async => []); return bloc; }, act: (b) => b.add(const SupprimerMessage( conversationId: 'conv-1', messageId: 'msg-1', )), expect: () => [ isA().having( (s) => s.action, 'action', 'supprimer-message', ), isA(), ], ); blocTest( 'emits [MessagingError] on delete failure', build: () { when(mockRepository.supprimerMessage(any, any)) .thenThrow(Exception('delete error')); return bloc; }, act: (b) => b.add(const SupprimerMessage( conversationId: 'conv-1', messageId: 'msg-1', )), expect: () => [isA()], ); }); // ── NouveauMessageWebSocket ─────────────────────────────────────────────── group('NouveauMessageWebSocket', () { blocTest( 'reloads messages when current conversation matches', build: () { // Open a conversation first so _currentConversationId is set when(mockRepository.getConversation('conv-1')) .thenAnswer((_) async => fakeConversation()); when(mockRepository.marquerLu(any)).thenAnswer((_) async {}); when(mockRepository.getMessages('conv-1', page: anyNamed('page'))) .thenAnswer((_) async => [fakeMessage()]); return bloc; }, act: (b) async { b.add(const OpenConversation('conv-1')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const NouveauMessageWebSocket('conv-1')); }, expect: () => [ isA(), isA(), isA(), ], ); blocTest( 'refreshes conversations list when ws message is for a different conversation', build: () { when(mockRepository.getMesConversations()) .thenAnswer((_) async => [fakeConversationSummary()]); return bloc; }, act: (b) => b.add(const NouveauMessageWebSocket('conv-other')), expect: () => [ isA(), isA(), ], verify: (_) { verify(mockRepository.getMesConversations()).called(1); }, ); }); // ── MarquerLu ───────────────────────────────────────────────────────────── group('MarquerLu', () { blocTest( 'calls marquerLu on repository without state change', build: () { when(mockRepository.marquerLu('conv-1')).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const MarquerLu('conv-1')), expect: () => [], verify: (_) => verify(mockRepository.marquerLu('conv-1')).called(1), ); }); }