import 'package:bloc_test/bloc_test.dart'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:unionflow_mobile_apps/core/config/environment.dart'; import 'package:unionflow_mobile_apps/features/notifications/data/models/notification_model.dart'; import 'package:unionflow_mobile_apps/features/notifications/data/repositories/notification_repository.dart'; import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notifications_bloc.dart'; @GenerateMocks([NotificationRepository]) import 'notifications_bloc_test.mocks.dart'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- NotificationModel _makeNotification({ String id = 'notif-1', String type = 'SYSTEME', bool read = false, String? statut, }) => NotificationModel( id: id, typeNotification: type, sujet: 'Sujet $id', corps: 'Corps de la notification', statut: statut ?? (read ? 'LUE' : 'NON_LUE'), dateLecture: read ? DateTime(2026, 1, 1) : null, ); DioException _makeDioException({int? statusCode, DioExceptionType? type}) => DioException( requestOptions: RequestOptions(path: '/notifications'), response: statusCode != null ? Response( requestOptions: RequestOptions(path: '/notifications'), statusCode: statusCode, ) : null, type: type ?? DioExceptionType.connectionError, ); // --------------------------------------------------------------------------- void main() { late MockNotificationRepository mockRepository; setUpAll(() { try { AppConfig.initialize(); } catch (_) { // Already initialized. } }); NotificationsBloc buildBloc() => NotificationsBloc(mockRepository); setUp(() { mockRepository = MockNotificationRepository(); }); // ─── Initial state ──────────────────────────────────────────────────────── group('initial state', () { test('is NotificationsInitial', () { expect(buildBloc().state, const NotificationsInitial()); }); }); // ─── LoadNotifications ──────────────────────────────────────────────────── group('LoadNotifications', () { final notifs = [ _makeNotification(), _makeNotification(id: 'notif-2', read: true), ]; blocTest( 'calls getMesNotifications when membreId is null', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenAnswer((_) async => notifs), act: (b) => b.add(const LoadNotifications()), verify: (_) { verify(mockRepository.getMesNotifications()).called(1); verifyNever(mockRepository.getNotificationsByMembre(any)); }, ); blocTest( 'calls getMesNotifications when membreId is empty string', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenAnswer((_) async => notifs), act: (b) => b.add(const LoadNotifications(membreId: '')), verify: (_) { verify(mockRepository.getMesNotifications()).called(1); }, ); blocTest( 'calls getNotificationsByMembre when membreId is provided', build: buildBloc, setUp: () => when(mockRepository.getNotificationsByMembre('membre-42')) .thenAnswer((_) async => notifs), act: (b) => b.add(const LoadNotifications(membreId: 'membre-42')), verify: (_) { verify(mockRepository.getNotificationsByMembre('membre-42')).called(1); verifyNever(mockRepository.getMesNotifications()); }, ); blocTest( 'emits [Loading, Loaded] with correct nonLuesCount', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenAnswer((_) async => notifs), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), isA() .having((s) => s.notifications.length, 'count', 2) .having((s) => s.nonLuesCount, 'nonLues', 1), ], ); blocTest( 'emits [Loading, Loaded(empty)] when no notifications', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenAnswer((_) async => []), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), const NotificationsLoaded(notifications: [], nonLuesCount: 0), ], ); blocTest( 'emits [Loading, Error] with 401 message on DioException 401', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()) .thenThrow(_makeDioException(statusCode: 401)), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), isA() .having((e) => e.message, 'message', 'Non autorisé.'), ], ); blocTest( 'emits [Loading, Error] with 403 message on DioException 403', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()) .thenThrow(_makeDioException(statusCode: 403)), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), isA() .having((e) => e.message, 'message', 'Accès refusé.'), ], ); blocTest( 'emits [Loading, Error] with server error on DioException 500', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()) .thenThrow(_makeDioException(statusCode: 500)), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), isA() .having((e) => e.message, 'message', 'Erreur serveur.'), ], ); blocTest( 'emits [Loading, Error] with network error on connection error', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()) .thenThrow(_makeDioException()), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), isA() .having((e) => e.message, 'message', contains('réseau')), ], ); blocTest( 'emits [Loading, Error] on generic exception', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenThrow(Exception('unexpected')), act: (b) => b.add(const LoadNotifications()), expect: () => [ const NotificationsLoading(), isA() .having((e) => e.message, 'message', contains('chargement')), ], ); blocTest( 'swallows DioException.cancel (no error emitted)', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenThrow( _makeDioException(type: DioExceptionType.cancel), ), act: (b) => b.add(const LoadNotifications()), expect: () => [const NotificationsLoading()], ); }); // ─── MarkNotificationAsRead ─────────────────────────────────────────────── group('MarkNotificationAsRead', () { final unread = _makeNotification(id: 'notif-1'); final read = _makeNotification(id: 'notif-2', read: true); final loadedState = NotificationsLoaded( notifications: [unread, read], nonLuesCount: 1, ); blocTest( 'emits NotificationMarkedAsRead with updated list and decremented count', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.marquerCommeLue('notif-1')).thenAnswer((_) async {}), act: (b) => b.add(const MarkNotificationAsRead('notif-1')), expect: () => [ isA() .having((s) => s.nonLuesCount, 'nonLues', 0) .having( (s) => s.notifications.firstWhere((n) => n.id == 'notif-1').statut, 'statut', 'LUE', ), ], ); blocTest( 'does nothing when state is not NotificationsLoaded', build: buildBloc, act: (b) => b.add(const MarkNotificationAsRead('notif-1')), expect: () => [], ); blocTest( 'emits Error when marquerCommeLue throws', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.marquerCommeLue(any)).thenThrow(Exception('fail')), act: (b) => b.add(const MarkNotificationAsRead('notif-1')), expect: () => [ isA() .having((e) => e.message, 'message', contains('marquer')), ], ); blocTest( 'swallows DioException.cancel in markAsRead', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.marquerCommeLue(any)).thenThrow( _makeDioException(type: DioExceptionType.cancel), ), act: (b) => b.add(const MarkNotificationAsRead('notif-1')), expect: () => [], ); blocTest( 'notification not in list is left unchanged', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.marquerCommeLue('unknown')).thenAnswer((_) async {}), act: (b) => b.add(const MarkNotificationAsRead('unknown')), expect: () => [ isA() .having((s) => s.nonLuesCount, 'nonLues', 1), // unchanged ], ); }); // ─── RefreshNotifications ───────────────────────────────────────────────── group('RefreshNotifications', () { final notifs = [_makeNotification()]; blocTest( 'triggers LoadNotifications with null membreId', build: buildBloc, setUp: () => when(mockRepository.getMesNotifications()).thenAnswer((_) async => notifs), act: (b) => b.add(const RefreshNotifications()), expect: () => [ const NotificationsLoading(), isA(), ], ); blocTest( 'triggers LoadNotifications with given membreId', build: buildBloc, setUp: () => when(mockRepository.getNotificationsByMembre('m-99')) .thenAnswer((_) async => notifs), act: (b) => b.add(const RefreshNotifications('m-99')), expect: () => [ const NotificationsLoading(), isA(), ], verify: (_) { verify(mockRepository.getNotificationsByMembre('m-99')).called(1); }, ); }); // ─── NotificationModel helpers ──────────────────────────────────────────── group('NotificationModel', () { test('estLue is true when statut == LUE', () { final n = _makeNotification(statut: 'LUE'); expect(n.estLue, true); }); test('estLue is true when dateLecture is set', () { final n = NotificationModel( id: 'x', typeNotification: 'SYSTEME', dateLecture: DateTime.now(), ); expect(n.estLue, true); }); test('estLue is false when neither statut nor dateLecture', () { final n = _makeNotification(); expect(n.estLue, false); }); test('typeAffichage returns correct labels', () { expect( NotificationModel(id: 'x', typeNotification: 'EVENEMENT').typeAffichage, 'Événements', ); expect( NotificationModel(id: 'x', typeNotification: 'COTISATION').typeAffichage, 'Cotisations', ); expect( NotificationModel(id: 'x', typeNotification: 'UNKNOWN').typeAffichage, 'Système', ); }); }); }