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/explore/presentation/bloc/network_bloc.dart'; import 'package:unionflow_mobile_apps/features/explore/presentation/bloc/network_event.dart'; import 'package:unionflow_mobile_apps/features/explore/presentation/bloc/network_state.dart'; import 'package:unionflow_mobile_apps/features/explore/data/repositories/network_repository.dart'; @GenerateMocks([NetworkRepository]) import 'network_bloc_test.mocks.dart'; void main() { late NetworkBloc bloc; late MockNetworkRepository mockRepository; // ── Fixtures ────────────────────────────────────────────────────────────── NetworkItem fakeMember({ String id = 'mbr-1', bool isConnected = false, }) => NetworkItem( id: id, name: 'Jean Dupont', subtitle: 'Comptable', type: 'Member', isConnected: isConnected, ); NetworkItem fakeOrg({String id = 'org-1'}) => NetworkItem( id: id, name: 'Association Test', subtitle: 'ASSOCIATION', type: 'Organization', isConnected: false, ); setUp(() { mockRepository = MockNetworkRepository(); bloc = NetworkBloc(mockRepository); }); tearDown(() => bloc.close()); // ── Initial state ───────────────────────────────────────────────────────── test('initial state is NetworkInitial', () { expect(bloc.state, isA()); }); // ── LoadNetworkRequested ────────────────────────────────────────────────── group('LoadNetworkRequested', () { blocTest( 'emits [NetworkLoading, NetworkLoaded] on success with followed members', build: () { when(mockRepository.getFollowedIds()) .thenAnswer((_) async => ['mbr-1', 'mbr-2']); when(mockRepository.search('', followedIds: anyNamed('followedIds'))) .thenAnswer((_) async => [ fakeMember(isConnected: true), fakeMember(id: 'mbr-2', isConnected: true), fakeOrg(), ]); return bloc; }, act: (b) => b.add(LoadNetworkRequested()), expect: () => [ isA(), isA() .having((s) => s.items.length, 'items.length', 3) .having((s) => s.currentQuery, 'currentQuery', ''), ], verify: (_) { verify(mockRepository.getFollowedIds()).called(1); verify(mockRepository.search('', followedIds: anyNamed('followedIds'))).called(1); }, ); blocTest( 'emits [NetworkLoading, NetworkLoaded] with empty list', build: () { when(mockRepository.getFollowedIds()).thenAnswer((_) async => []); when(mockRepository.search('', followedIds: anyNamed('followedIds'))) .thenAnswer((_) async => []); return bloc; }, act: (b) => b.add(LoadNetworkRequested()), expect: () => [ isA(), isA().having((s) => s.items, 'items', isEmpty), ], ); blocTest( 'emits [NetworkLoading, NetworkError] on getFollowedIds failure', build: () { when(mockRepository.getFollowedIds()).thenThrow(Exception('auth error')); return bloc; }, act: (b) => b.add(LoadNetworkRequested()), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Erreur'), ), ], ); blocTest( 'emits [NetworkLoading, NetworkError] on search failure', build: () { when(mockRepository.getFollowedIds()).thenAnswer((_) async => []); when(mockRepository.search(any, followedIds: anyNamed('followedIds'))) .thenThrow(Exception('search error')); return bloc; }, act: (b) => b.add(LoadNetworkRequested()), expect: () => [isA(), isA()], ); }); // ── SearchNetworkRequested ──────────────────────────────────────────────── group('SearchNetworkRequested', () { blocTest( 'emits [NetworkLoading, NetworkLoaded] with search results', build: () { when(mockRepository.getFollowedIds()).thenAnswer((_) async => ['mbr-1']); when(mockRepository.search('Jean', followedIds: anyNamed('followedIds'))) .thenAnswer((_) async => [fakeMember(isConnected: true)]); return bloc; }, act: (b) => b.add(const SearchNetworkRequested('Jean')), expect: () => [ isA(), isA() .having((s) => s.currentQuery, 'currentQuery', 'Jean') .having((s) => s.items.length, 'items.length', 1), ], verify: (_) { verify(mockRepository.search('Jean', followedIds: anyNamed('followedIds'))).called(1); }, ); blocTest( 'emits [NetworkLoading, NetworkLoaded] with empty query (no search)', build: () { when(mockRepository.getFollowedIds()).thenAnswer((_) async => []); when(mockRepository.search('', followedIds: anyNamed('followedIds'))) .thenAnswer((_) async => [fakeMember()]); return bloc; }, act: (b) => b.add(const SearchNetworkRequested('')), expect: () => [ isA(), isA().having((s) => s.currentQuery, 'currentQuery', ''), ], ); blocTest( 'emits [NetworkLoading, NetworkLoaded] with whitespace-only query (treated as empty)', build: () { when(mockRepository.getFollowedIds()).thenAnswer((_) async => []); when(mockRepository.search('', followedIds: anyNamed('followedIds'))) .thenAnswer((_) async => []); return bloc; }, act: (b) => b.add(const SearchNetworkRequested(' ')), expect: () => [ isA(), isA().having((s) => s.currentQuery, 'currentQuery', ''), ], ); blocTest( 'emits [NetworkLoading, NetworkError] on search failure', build: () { when(mockRepository.getFollowedIds()).thenAnswer((_) async => []); when(mockRepository.search(any, followedIds: anyNamed('followedIds'))) .thenThrow(Exception('search failed')); return bloc; }, act: (b) => b.add(const SearchNetworkRequested('error')), expect: () => [ isA(), isA(), ], ); }); // ── ToggleFollowRequested ───────────────────────────────────────────────── group('ToggleFollowRequested — Member type', () { // Prerequisite: put bloc in NetworkLoaded state first final loadedState = NetworkLoaded( items: [ fakeMember(id: 'mbr-1', isConnected: false), fakeOrg(), ], currentQuery: 'test', ); blocTest( 'follows an unfollowed Member and emits NetworkLoaded with isConnected=true', build: () { when(mockRepository.follow('mbr-1')).thenAnswer((_) async => true); return bloc; }, seed: () => loadedState, act: (b) => b.add(const ToggleFollowRequested('mbr-1')), expect: () => [ isA().having( (s) => s.items.firstWhere((i) => i.id == 'mbr-1').isConnected, 'mbr-1.isConnected', true, ), ], verify: (_) => verify(mockRepository.follow('mbr-1')).called(1), ); blocTest( 'unfollows a followed Member and emits NetworkLoaded with isConnected=false', build: () { when(mockRepository.unfollow('mbr-1')).thenAnswer((_) async => false); return bloc; }, seed: () => NetworkLoaded( items: [fakeMember(id: 'mbr-1', isConnected: true)], currentQuery: '', ), act: (b) => b.add(const ToggleFollowRequested('mbr-1')), expect: () => [ isA().having( (s) => s.items.first.isConnected, 'isConnected', false, ), ], verify: (_) => verify(mockRepository.unfollow('mbr-1')).called(1), ); blocTest( 'emits [NetworkError] when follow call fails', build: () { when(mockRepository.follow(any)).thenThrow(Exception('follow failed')); return bloc; }, seed: () => loadedState, act: (b) => b.add(const ToggleFollowRequested('mbr-1')), expect: () => [isA()], ); }); group('ToggleFollowRequested — Organization type (local toggle only)', () { blocTest( 'toggles Organization follow locally without calling repository', build: () => bloc, seed: () => NetworkLoaded( items: [fakeOrg(id: 'org-1')], currentQuery: '', ), act: (b) => b.add(const ToggleFollowRequested('org-1')), expect: () => [ isA().having( (s) => s.items.first.isConnected, 'isConnected', true, ), ], verify: (_) { verifyNever(mockRepository.follow(any)); verifyNever(mockRepository.unfollow(any)); }, ); }); group('ToggleFollowRequested — guard: item not in list', () { blocTest( 'does nothing when item id not found in current list', build: () => bloc, seed: () => NetworkLoaded( items: [fakeMember(id: 'mbr-1')], currentQuery: '', ), act: (b) => b.add(const ToggleFollowRequested('non-existent')), expect: () => [], ); blocTest( 'does nothing when state is not NetworkLoaded', build: () => bloc, // Default initial state (NetworkInitial) act: (b) => b.add(const ToggleFollowRequested('mbr-1')), expect: () => [], ); }); }