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/repositories/notification_feed_repository.dart'; import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notification_bloc.dart'; import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notification_event.dart'; import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notification_state.dart'; @GenerateMocks([NotificationFeedRepository]) import 'notification_bloc_test.mocks.dart'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- NotificationItem _makeItem({ String id = 'item-1', String title = 'Test Notification', String body = 'Corps du message', bool isRead = false, String category = 'system', }) => NotificationItem( id: id, title: title, body: body, date: DateTime(2026, 4, 20), isRead: isRead, category: category, ); DioException _cancelException() => DioException( requestOptions: RequestOptions(path: '/notifications'), type: DioExceptionType.cancel, ); // --------------------------------------------------------------------------- void main() { late MockNotificationFeedRepository mockRepository; setUpAll(() { try { AppConfig.initialize(); } catch (_) { // Already initialized. } }); NotificationBloc buildBloc() => NotificationBloc(mockRepository); setUp(() { mockRepository = MockNotificationFeedRepository(); }); // ─── Initial state ──────────────────────────────────────────────────────── group('initial state', () { test('is NotificationInitial', () { expect(buildBloc().state, isA()); }); }); // ─── LoadNotificationsRequested ─────────────────────────────────────────── group('LoadNotificationsRequested', () { final items = [ _makeItem(), _makeItem(id: 'item-2', title: 'Finance Alert', category: 'finance'), _makeItem(id: 'item-3', title: 'Event', category: 'event', isRead: true), ]; blocTest( 'emits [Loading, Loaded] with items on success', build: buildBloc, setUp: () => when(mockRepository.getNotifications()).thenAnswer((_) async => items), act: (b) => b.add(LoadNotificationsRequested()), expect: () => [ isA(), isA() .having((s) => s.items.length, 'count', 3) .having((s) => s.items.first.id, 'first id', 'item-1'), ], ); blocTest( 'emits [Loading, Loaded(empty)] when repository returns empty list', build: buildBloc, setUp: () => when(mockRepository.getNotifications()).thenAnswer((_) async => []), act: (b) => b.add(LoadNotificationsRequested()), expect: () => [ isA(), isA().having((s) => s.items, 'items', isEmpty), ], ); blocTest( 'emits [Loading, Error] on generic exception', build: buildBloc, setUp: () => when(mockRepository.getNotifications()).thenThrow(Exception('network')), act: (b) => b.add(LoadNotificationsRequested()), expect: () => [ isA(), isA() .having((e) => e.message, 'message', contains('chargement')), ], ); blocTest( 'emits [Loading, Error] on DioException (non-cancel)', build: buildBloc, setUp: () => when(mockRepository.getNotifications()).thenThrow( DioException( requestOptions: RequestOptions(path: '/notifications'), type: DioExceptionType.connectionError, ), ), act: (b) => b.add(LoadNotificationsRequested()), expect: () => [ isA(), isA(), ], ); blocTest( 'swallows DioException.cancel — no error emitted', build: buildBloc, setUp: () => when(mockRepository.getNotifications()).thenThrow(_cancelException()), act: (b) => b.add(LoadNotificationsRequested()), expect: () => [isA()], ); blocTest( 'calls getNotifications exactly once', build: buildBloc, setUp: () => when(mockRepository.getNotifications()).thenAnswer((_) async => items), act: (b) => b.add(LoadNotificationsRequested()), verify: (_) => verify(mockRepository.getNotifications()).called(1), ); }); // ─── NotificationMarkedAsRead ───────────────────────────────────────────── group('NotificationMarkedAsRead', () { final items = [ _makeItem(id: 'item-1', isRead: false), _makeItem(id: 'item-2', isRead: false), _makeItem(id: 'item-3', isRead: true), ]; final loadedState = NotificationLoaded(items: items); blocTest( 'emits Loaded with target item marked as read', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead('item-1')).thenAnswer((_) async {}), act: (b) => b.add(const NotificationMarkedAsRead('item-1')), expect: () => [ isA() .having( (s) => s.items.firstWhere((i) => i.id == 'item-1').isRead, 'item-1 isRead', true, ) .having( (s) => s.items.firstWhere((i) => i.id == 'item-2').isRead, 'item-2 unchanged', false, ), ], ); blocTest( 'preserves all other item properties after marking read', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead('item-1')).thenAnswer((_) async {}), act: (b) => b.add(const NotificationMarkedAsRead('item-1')), expect: () => [ isA().having( (s) => s.items.firstWhere((i) => i.id == 'item-1').title, 'title preserved', 'Test Notification', ), ], ); blocTest( 'no re-emission when id not found (items unchanged, equatable dedup)', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead('unknown')).thenAnswer((_) async {}), act: (b) => b.add(const NotificationMarkedAsRead('unknown')), // The map returns the same object references for non-matching items, // resulting in an identical NotificationLoaded state — no re-emission. expect: () => [], ); blocTest( 'does nothing when state is not NotificationLoaded', build: buildBloc, // state is NotificationInitial act: (b) => b.add(const NotificationMarkedAsRead('item-1')), expect: () => [], ); blocTest( 'does nothing when state is NotificationLoading', build: buildBloc, seed: () => NotificationLoading(), act: (b) => b.add(const NotificationMarkedAsRead('item-1')), expect: () => [], ); blocTest( 'emits Error when markAsRead throws', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead(any)).thenThrow(Exception('server error')), act: (b) => b.add(const NotificationMarkedAsRead('item-1')), expect: () => [ isA() .having((e) => e.message, 'message', contains('marquer')), ], ); blocTest( 'swallows DioException.cancel in markAsRead', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead(any)).thenThrow(_cancelException()), act: (b) => b.add(const NotificationMarkedAsRead('item-1')), expect: () => [], ); blocTest( 'calls markAsRead with correct id', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead('item-2')).thenAnswer((_) async {}), act: (b) => b.add(const NotificationMarkedAsRead('item-2')), verify: (_) => verify(mockRepository.markAsRead('item-2')).called(1), ); blocTest( 'marking already-read item produces same state (no re-emission)', build: buildBloc, seed: () => loadedState, setUp: () => when(mockRepository.markAsRead('item-3')).thenAnswer((_) async {}), act: (b) => b.add(const NotificationMarkedAsRead('item-3')), // Re-marking an already-read item rebuilds NotificationLoaded with // identical items (isRead=true stays true). Equatable dedup prevents // re-emission because the resulting state equals the current state. expect: () => [], ); }); // ─── Combined flows ─────────────────────────────────────────────────────── group('combined flows', () { final items = [ _makeItem(id: 'a', isRead: false), _makeItem(id: 'b', isRead: false), ]; blocTest( 'load then mark as read produces correct final state', build: buildBloc, setUp: () { when(mockRepository.getNotifications()).thenAnswer((_) async => items); when(mockRepository.markAsRead('a')).thenAnswer((_) async {}); }, act: (b) async { b.add(LoadNotificationsRequested()); await Future.delayed(Duration.zero); b.add(const NotificationMarkedAsRead('a')); }, expect: () => [ isA(), isA().having((s) => s.items.length, 'count', 2), isA().having( (s) => s.items.firstWhere((i) => i.id == 'a').isRead, 'a is read', true, ), ], ); blocTest( 'reload after mark as read reflects fresh data', build: buildBloc, setUp: () { final updated = [ _makeItem(id: 'a', isRead: true), _makeItem(id: 'b', isRead: false), ]; when(mockRepository.getNotifications()) .thenAnswer((_) async => updated); }, seed: () => NotificationLoaded(items: items), act: (b) => b.add(LoadNotificationsRequested()), expect: () => [ isA(), isA().having( (s) => s.items.firstWhere((i) => i.id == 'a').isRead, 'a is read after reload', true, ), ], ); }); // ─── NotificationItem equality ──────────────────────────────────────────── group('NotificationItem', () { test('two items with same data are equal', () { final a = _makeItem(); final b = _makeItem(); expect(a, b); }); test('items with different isRead are not equal', () { final a = _makeItem(isRead: false); final b = _makeItem(isRead: true); expect(a, isNot(b)); }); test('items with different category are not equal', () { final a = _makeItem(category: 'system'); final b = _makeItem(category: 'finance'); expect(a, isNot(b)); }); }); // ─── State equality ─────────────────────────────────────────────────────── group('State equality', () { final items = [_makeItem()]; test('NotificationLoaded with same items are equal', () { expect(NotificationLoaded(items: items), NotificationLoaded(items: items)); }); test('NotificationError with same message are equal', () { expect( const NotificationError('fail'), const NotificationError('fail'), ); }); }); }