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/features/profile/presentation/bloc/profile_bloc.dart'; import 'package:unionflow_mobile_apps/features/profile/domain/usecases/get_profile.dart'; import 'package:unionflow_mobile_apps/features/profile/domain/usecases/update_profile.dart'; import 'package:unionflow_mobile_apps/features/profile/domain/repositories/profile_repository.dart'; import 'package:unionflow_mobile_apps/features/members/data/models/membre_complete_model.dart'; @GenerateMocks([GetProfile, UpdateProfile, IProfileRepository]) import 'profile_bloc_test.mocks.dart'; // ─── Fixtures ──────────────────────────────────────────────────────────────── MembreCompletModel _membre({String id = 'membre-1'}) => MembreCompletModel( id: id, nom: 'Dupont', prenom: 'Jean', email: 'jean.dupont@test.com', ); DioException _dioError(int statusCode) => DioException( requestOptions: RequestOptions(path: '/api/membres/me'), response: Response( requestOptions: RequestOptions(path: '/api/membres/me'), statusCode: statusCode, ), type: DioExceptionType.badResponse, ); DioException _networkError() => DioException( requestOptions: RequestOptions(path: '/api/membres/me'), type: DioExceptionType.connectionTimeout, ); // ─── Tests ─────────────────────────────────────────────────────────────────── void main() { late ProfileBloc bloc; late MockGetProfile mockGetProfile; late MockUpdateProfile mockUpdateProfile; late MockIProfileRepository mockRepository; setUp(() { mockGetProfile = MockGetProfile(); mockUpdateProfile = MockUpdateProfile(); mockRepository = MockIProfileRepository(); bloc = ProfileBloc(mockGetProfile, mockUpdateProfile, mockRepository); }); tearDown(() => bloc.close()); // ─── Initial state ────────────────────────────────────────────────────────── test('initial state is ProfileInitial', () { expect(bloc.state, isA()); }); // ─── LoadMe ───────────────────────────────────────────────────────────────── group('LoadMe', () { blocTest( 'emits [ProfileLoading, ProfileLoaded] on success', build: () { when(mockGetProfile()).thenAnswer((_) async => _membre()); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.membre.id, 'id', 'membre-1') .having((s) => s.membre.email, 'email', 'jean.dupont@test.com'), ], ); blocTest( 'emits [ProfileLoading, ProfileNotFound] when getProfile returns null', build: () { when(mockGetProfile()).thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [isA(), isA()], ); blocTest( 'emits [ProfileLoading, ProfileError] on DioException (401)', build: () { when(mockGetProfile()).thenThrow(_dioError(401)); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Non autorisé'), ), ], ); blocTest( 'emits [ProfileLoading, ProfileError] on DioException (403)', build: () { when(mockGetProfile()).thenThrow(_dioError(403)); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Accès refusé')), ], ); blocTest( 'emits [ProfileLoading, ProfileError] on DioException (404)', build: () { when(mockGetProfile()).thenThrow(_dioError(404)); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('non trouvé')), ], ); blocTest( 'emits [ProfileLoading, ProfileError] on DioException (500)', build: () { when(mockGetProfile()).thenThrow(_dioError(500)); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Erreur serveur')), ], ); blocTest( 'emits [ProfileLoading, ProfileError] on network timeout', build: () { when(mockGetProfile()).thenThrow(_networkError()); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Délai de connexion'), ), ], ); blocTest( 'emits [ProfileLoading, ProfileError] on generic exception', build: () { when(mockGetProfile()).thenThrow(Exception('Unexpected failure')); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Erreur lors du chargement'), ), ], ); }); // ─── LoadMyProfile ────────────────────────────────────────────────────────── group('LoadMyProfile', () { blocTest( 'emits [ProfileLoading, ProfileLoaded] on success', build: () { when(mockRepository.getProfileByEmail('jean@test.com')) .thenAnswer((_) async => _membre()); return bloc; }, act: (b) => b.add(const LoadMyProfile('jean@test.com')), expect: () => [ isA(), isA().having((s) => s.membre.id, 'id', 'membre-1'), ], ); blocTest( 'emits [ProfileLoading, ProfileNotFound] when repository returns null', build: () { when(mockRepository.getProfileByEmail(any)) .thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const LoadMyProfile('unknown@test.com')), expect: () => [isA(), isA()], ); blocTest( 'emits [ProfileLoading, ProfileError] on DioException', build: () { when(mockRepository.getProfileByEmail(any)) .thenThrow(_dioError(404)); return bloc; }, act: (b) => b.add(const LoadMyProfile('jean@test.com')), expect: () => [isA(), isA()], ); blocTest( 'emits [ProfileLoading, ProfileError] on generic exception', build: () { when(mockRepository.getProfileByEmail(any)) .thenThrow(Exception('DB error')); return bloc; }, act: (b) => b.add(const LoadMyProfile('jean@test.com')), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Erreur lors du chargement')), ], ); }); // ─── UpdateMyProfile ──────────────────────────────────────────────────────── group('UpdateMyProfile', () { blocTest( 'emits [ProfileUpdating, ProfileUpdated] when state is ProfileLoaded on success', build: () { final updatedMembre = _membre(id: 'membre-1'); when(mockUpdateProfile('membre-1', any)) .thenAnswer((_) async => updatedMembre); return bloc; }, seed: () => ProfileLoaded(_membre()), act: (b) => b.add(UpdateMyProfile( membreId: 'membre-1', membre: _membre(), )), expect: () => [ isA().having((s) => s.membre.id, 'id', 'membre-1'), isA().having((s) => s.membre.id, 'id', 'membre-1'), ], ); blocTest( 'emits [ProfileUpdated] from ProfileInitial (no ProfileUpdating)', build: () { when(mockUpdateProfile('membre-1', any)) .thenAnswer((_) async => _membre()); return bloc; }, // No seed → initial state is ProfileInitial (not ProfileLoaded) act: (b) => b.add(UpdateMyProfile( membreId: 'membre-1', membre: _membre(), )), expect: () => [isA()], ); blocTest( 'emits [ProfileUpdating, ProfileLoaded, ProfileError] on DioException when state is ProfileLoaded', build: () { when(mockUpdateProfile(any, any)).thenThrow(_dioError(400)); return bloc; }, seed: () => ProfileLoaded(_membre()), act: (b) => b.add(UpdateMyProfile( membreId: 'membre-1', membre: _membre(), )), expect: () => [ isA(), isA(), // restored previous state isA(), ], ); blocTest( 'emits [ProfileError] on DioException when state is not ProfileLoaded', build: () { when(mockUpdateProfile(any, any)).thenThrow(_dioError(500)); return bloc; }, act: (b) => b.add(UpdateMyProfile( membreId: 'membre-1', membre: _membre(), )), expect: () => [isA()], ); blocTest( 'emits [ProfileUpdating, ProfileError] on generic exception when state is ProfileLoaded', build: () { when(mockUpdateProfile(any, any)) .thenThrow(Exception('Serialization error')); return bloc; }, seed: () => ProfileLoaded(_membre()), act: (b) => b.add(UpdateMyProfile( membreId: 'membre-1', membre: _membre(), )), expect: () => [ isA(), isA(), isA().having( (s) => s.message, 'message', contains('mise à jour'), ), ], ); }); // ─── ChangePassword ───────────────────────────────────────────────────────── group('ChangePassword', () { blocTest( 'emits [PasswordChanging, PasswordChanged] on success from ProfileInitial', build: () { when(mockRepository.changePassword('membre-1', 'old123', 'new456')) .thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const ChangePassword( membreId: 'membre-1', oldPassword: 'old123', newPassword: 'new456', )), expect: () => [isA(), isA()], ); blocTest( 'restores ProfileLoaded after PasswordChanged when previous state was ProfileLoaded', build: () { when(mockRepository.changePassword(any, any, any)) .thenAnswer((_) async {}); return bloc; }, seed: () => ProfileLoaded(_membre()), act: (b) => b.add(const ChangePassword( membreId: 'membre-1', oldPassword: 'old123', newPassword: 'new456', )), expect: () => [ isA(), isA(), isA(), // restored ], ); blocTest( 'emits [PasswordChanging, ProfileError] on DioException (403)', build: () { when(mockRepository.changePassword(any, any, any)) .thenThrow(_dioError(403)); return bloc; }, act: (b) => b.add(const ChangePassword( membreId: 'membre-1', oldPassword: 'wrong', newPassword: 'new', )), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Accès refusé')), ], ); blocTest( 'emits [PasswordChanging, ProfileError, ProfileLoaded] on DioException when state is ProfileLoaded', build: () { when(mockRepository.changePassword(any, any, any)) .thenThrow(_dioError(401)); return bloc; }, seed: () => ProfileLoaded(_membre()), act: (b) => b.add(const ChangePassword( membreId: 'membre-1', oldPassword: 'old', newPassword: 'new', )), expect: () => [ isA(), isA(), isA(), // restored ], ); blocTest( 'emits [PasswordChanging, ProfileError] on generic exception', build: () { when(mockRepository.changePassword(any, any, any)) .thenThrow(Exception('Wrong old password')); return bloc; }, act: (b) => b.add(const ChangePassword( membreId: 'membre-1', oldPassword: 'wrong', newPassword: 'new456', )), expect: () => [ isA(), isA(), ], ); blocTest( 'strips Exception: prefix from generic error message', build: () { when(mockRepository.changePassword(any, any, any)) .thenThrow(Exception('Custom error message')); return bloc; }, act: (b) => b.add(const ChangePassword( membreId: 'membre-1', oldPassword: 'x', newPassword: 'y', )), expect: () => [ isA(), isA().having( (s) => s.message, 'message', isNot(contains('Exception:')), ), ], ); }); // ─── DeleteAccount ────────────────────────────────────────────────────────── group('DeleteAccount', () { blocTest( 'emits [AccountDeleting, AccountDeleted] on success', build: () { when(mockRepository.deleteAccount('membre-1')) .thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const DeleteAccount('membre-1')), expect: () => [isA(), isA()], ); blocTest( 'emits [AccountDeleting, ProfileError] on DioException (401)', build: () { when(mockRepository.deleteAccount(any)).thenThrow(_dioError(401)); return bloc; }, act: (b) => b.add(const DeleteAccount('membre-1')), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Non autorisé')), ], ); blocTest( 'emits [AccountDeleting, ProfileError] on DioException (403)', build: () { when(mockRepository.deleteAccount(any)).thenThrow(_dioError(403)); return bloc; }, act: (b) => b.add(const DeleteAccount('membre-1')), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Accès refusé')), ], ); blocTest( 'emits [AccountDeleting, ProfileError] on network error', build: () { when(mockRepository.deleteAccount(any)).thenThrow(_networkError()); return bloc; }, act: (b) => b.add(const DeleteAccount('membre-1')), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Délai de connexion'), ), ], ); blocTest( 'emits [AccountDeleting, ProfileError] on generic exception', build: () { when(mockRepository.deleteAccount(any)) .thenThrow(Exception('Server unreachable')); return bloc; }, act: (b) => b.add(const DeleteAccount('membre-1')), expect: () => [isA(), isA()], ); }); // ─── _networkErrorMessage coverage ───────────────────────────────────────── group('_networkErrorMessage DioExceptionType coverage', () { blocTest( 'sendTimeout returns Délai de connexion dépassé', build: () { when(mockGetProfile()).thenThrow(DioException( requestOptions: RequestOptions(path: '/'), type: DioExceptionType.sendTimeout, )); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Délai de connexion')), ], ); blocTest( 'receiveTimeout returns Délai de connexion dépassé', build: () { when(mockGetProfile()).thenThrow(DioException( requestOptions: RequestOptions(path: '/'), type: DioExceptionType.receiveTimeout, )); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Délai de connexion')), ], ); blocTest( 'unknown DioExceptionType returns Erreur réseau', build: () { when(mockGetProfile()).thenThrow(DioException( requestOptions: RequestOptions(path: '/'), type: DioExceptionType.unknown, )); return bloc; }, act: (b) => b.add(const LoadMe()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Erreur réseau')), ], ); }); // ─── State equality ───────────────────────────────────────────────────────── group('ProfileState equality', () { test('ProfileLoaded with same membre are equal', () { final m = _membre(); expect(ProfileLoaded(m), equals(ProfileLoaded(m))); }); test('ProfileError with same message are equal', () { const s1 = ProfileError('error'); const s2 = ProfileError('error'); expect(s1, equals(s2)); }); test('ProfileError with different messages are not equal', () { expect(const ProfileError('a'), isNot(equals(const ProfileError('b')))); }); test('ProfileUpdating contains membre in props', () { final m = _membre(); expect(ProfileUpdating(m).props, contains(m)); }); test('ProfileUpdated contains membre in props', () { final m = _membre(); expect(ProfileUpdated(m).props, contains(m)); }); }); // ─── ProfileEvent equality ────────────────────────────────────────────────── group('ProfileEvent equality', () { test('LoadMe events are equal', () { expect(const LoadMe(), equals(const LoadMe())); }); test('LoadMyProfile with same email are equal', () { expect( const LoadMyProfile('a@b.com'), equals(const LoadMyProfile('a@b.com')), ); }); test('ChangePassword props are correct', () { const event = ChangePassword( membreId: 'm-1', oldPassword: 'old', newPassword: 'new', ); expect(event.props, containsAll(['m-1', 'old', 'new'])); }); test('DeleteAccount props are correct', () { const event = DeleteAccount('m-99'); expect(event.props, contains('m-99')); }); }); }