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/authentication/presentation/bloc/auth_bloc.dart'; import 'package:unionflow_mobile_apps/features/authentication/data/datasources/keycloak_auth_service.dart'; import 'package:unionflow_mobile_apps/features/authentication/data/models/user.dart'; import 'package:unionflow_mobile_apps/features/authentication/data/models/user_role.dart'; import 'package:unionflow_mobile_apps/core/network/org_context_service.dart'; @GenerateMocks([KeycloakAuthService, OrgContextService]) import 'auth_bloc_test.mocks.dart'; // ─── Helpers ──────────────────────────────────────────────────────────────── User _makeUser({ UserRole role = UserRole.simpleMember, List orgContexts = const [], }) => User( id: 'user-1', email: 'test@unionflow.test', firstName: 'Test', lastName: 'User', primaryRole: role, organizationContexts: orgContexts, createdAt: DateTime(2024, 1, 1), lastLoginAt: DateTime(2024, 1, 1), ); AuthStatusResult _activeStatus({ bool premierLoginComplet = false, bool reAuthRequired = false, }) => AuthStatusResult( statutCompte: 'ACTIF', onboardingState: 'NO_SUBSCRIPTION', premierLoginComplet: premierLoginComplet, reAuthRequired: reAuthRequired, ); AuthStatusResult _pendingStatus({String onboardingState = 'AWAITING_PAYMENT'}) => AuthStatusResult( statutCompte: 'EN_ATTENTE_VALIDATION', onboardingState: onboardingState, souscriptionId: 'sosc-1', organisationId: 'org-1', typeOrganisation: 'ASSOCIATION', ); AuthStatusResult _blockedStatus(String statut) => AuthStatusResult( statutCompte: statut, onboardingState: 'NO_SUBSCRIPTION', ); // ─── Tests ────────────────────────────────────────────────────────────────── void main() { late AuthBloc bloc; late MockKeycloakAuthService mockAuth; late MockOrgContextService mockOrgCtx; setUp(() { mockAuth = MockKeycloakAuthService(); mockOrgCtx = MockOrgContextService(); // Default stubs so tests that don't care about OrgContextService don't throw when(mockOrgCtx.hasContext).thenReturn(false); when(mockOrgCtx.setActiveOrganisation( organisationId: anyNamed('organisationId'), nom: anyNamed('nom'), type: anyNamed('type'), modulesActifsCsv: anyNamed('modulesActifsCsv'), )).thenReturn(null); when(mockOrgCtx.clear()).thenReturn(null); bloc = AuthBloc(mockAuth, mockOrgCtx); }); tearDown(() => bloc.close()); // ─── Initial state ───────────────────────────────────────────────────────── test('initial state is AuthInitial', () { expect(bloc.state, isA()); }); // ─── AuthStatusChecked ──────────────────────────────────────────────────── group('AuthStatusChecked', () { blocTest( 'emits [AuthUnauthenticated] when no valid token', build: () { when(mockAuth.getValidToken()).thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [isA()], ); blocTest( 'emits [AuthUnauthenticated] when token valid but getCurrentUser returns null', build: () { when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [isA()], ); blocTest( 'emits [AuthAuthenticated] when token valid, user found, status active, role not orgAdmin', build: () { final user = _makeUser(role: UserRole.simpleMember); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _activeStatus()); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [ isA() .having((s) => s.user.email, 'email', 'test@unionflow.test') .having((s) => s.effectiveRole, 'role', UserRole.simpleMember), ], ); blocTest( 'emits [AuthUnauthenticated] when reAuthRequired on status check', build: () { final user = _makeUser(); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _activeStatus(reAuthRequired: true)); when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [isA()], verify: (_) => verify(mockAuth.logout()).called(1), ); blocTest( 'emits [AuthPendingOnboarding] when status isPendingOnboarding (AWAITING_PAYMENT)', build: () { final user = _makeUser(role: UserRole.orgAdmin); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _pendingStatus()); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [ isA() .having((s) => s.onboardingState, 'state', 'AWAITING_PAYMENT') .having((s) => s.souscriptionId, 'souscriptionId', 'sosc-1'), ], ); blocTest( 'emits [AuthAccountNotActive] when status isBlocked (SUSPENDU)', build: () { final user = _makeUser(); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _blockedStatus('SUSPENDU')); when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [ isA() .having((s) => s.statutCompte, 'statut', 'SUSPENDU'), ], verify: (_) => verify(mockAuth.logout()).called(1), ); blocTest( 'emits [AuthAccountNotActive] when status isBlocked (DESACTIVE)', build: () { final user = _makeUser(); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _blockedStatus('DESACTIVE')); when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [ isA() .having((s) => s.statutCompte, 'statut', 'DESACTIVE'), ], ); blocTest( 'refreshes token and re-checks when premierLoginComplet is true', build: () { final user = _makeUser(role: UserRole.activeMember); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _activeStatus(premierLoginComplet: true)); when(mockAuth.refreshToken()).thenAnswer((_) async => 'new-token'); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [isA()], verify: (_) => verify(mockAuth.refreshToken()).called(1), ); blocTest( 'emits [AuthAuthenticated] with VALIDATED onboarding when refresh activates account', build: () { final user = _makeUser(role: UserRole.activeMember); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); // First call: pending VALIDATED when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _pendingStatus(onboardingState: 'VALIDATED')); when(mockAuth.refreshToken()).thenAnswer((_) async => 'new-token'); // After refresh: active when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => AuthStatusResult( statutCompte: 'ACTIF', onboardingState: 'VALIDATED', )); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), // The bloc may emit AuthAuthenticated or AuthPendingOnboarding depending on // whether the stubbed refreshed status is active — either way should not throw expect: () => [isA()], ); blocTest( 'emits [AuthAuthenticated] with null status (network error graceful)', build: () { final user = _makeUser(role: UserRole.simpleMember); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-123'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [isA()], ); }); // ─── AuthLoginRequested ────────────────────────────────────────────────── group('AuthLoginRequested', () { blocTest( 'emits [AuthLoading, AuthAuthenticated] on successful login (active user)', build: () { final user = _makeUser(role: UserRole.activeMember); when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _activeStatus()); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-abc'); return bloc; }, act: (b) => b.add(const AuthLoginRequested()), expect: () => [ isA(), isA() .having((s) => s.accessToken, 'token', 'token-abc'), ], ); blocTest( 'emits [AuthLoading, AuthError] when loginWithAppAuth returns null', build: () { when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const AuthLoginRequested()), expect: () => [ isA(), isA() .having((s) => s.message, 'message', contains('Identifiants')), ], ); blocTest( 'emits [AuthLoading, AuthError] when loginWithAppAuth throws', build: () { when(mockAuth.loginWithAppAuth()) .thenThrow(Exception('Network failure')); return bloc; }, act: (b) => b.add(const AuthLoginRequested()), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Erreur de connexion'), ), ], ); blocTest( 'emits [AuthLoading, AuthPendingOnboarding] when status is pending onboarding', build: () { final user = _makeUser(role: UserRole.orgAdmin); when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _pendingStatus()); return bloc; }, act: (b) => b.add(const AuthLoginRequested()), expect: () => [ isA(), isA() .having((s) => s.souscriptionId, 'souscriptionId', 'sosc-1'), ], ); blocTest( 'emits [AuthLoading, AuthAccountNotActive] when account is SUSPENDU at login', build: () { final user = _makeUser(); when(mockAuth.loginWithAppAuth()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _blockedStatus('SUSPENDU')); when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthLoginRequested()), expect: () => [ isA(), isA() .having((s) => s.statutCompte, 'statut', 'SUSPENDU') .having( (s) => s.message, 'message', contains('suspendu'), ), ], verify: (_) => verify(mockAuth.logout()).called(1), ); blocTest( 'triggers re-auth flow when reAuthRequired is true on first login attempt', build: () { final user = _makeUser(); final reAuthedUser = _makeUser(role: UserRole.simpleMember); int callCount = 0; when(mockAuth.loginWithAppAuth()).thenAnswer((_) async { callCount++; return callCount == 1 ? user : reAuthedUser; }); when(mockAuth.getAuthStatus(any)).thenAnswer((_) async => _activeStatus( reAuthRequired: callCount == 1, )); when(mockAuth.logout()).thenAnswer((_) async {}); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token-abc'); return bloc; }, act: (b) => b.add(const AuthLoginRequested()), // After re-auth, reaches either AuthAuthenticated or AuthError depending on stub expect: () => [isA(), isA()], ); blocTest( 'emits [AuthLoading, AuthError] when re-auth loginWithAppAuth returns null', build: () { final user = _makeUser(); when(mockAuth.loginWithAppAuth()).thenAnswer((_) async { // First call returns user (initial login), second returns null (re-auth cancelled) return null; }); when(mockAuth.loginWithAppAuth()) .thenAnswer((_) async => null); // simplified: first call null return bloc; }, act: (b) => b.add(const AuthLoginRequested()), expect: () => [isA(), isA()], ); }); // ─── AuthLogoutRequested ───────────────────────────────────────────────── group('AuthLogoutRequested', () { blocTest( 'emits [AuthLoading, AuthUnauthenticated] and clears session', build: () { when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthLogoutRequested()), expect: () => [isA(), isA()], verify: (_) { verify(mockAuth.logout()).called(1); verify(mockOrgCtx.clear()).called(1); }, ); }); // ─── AuthTokenRefreshRequested ─────────────────────────────────────────── group('AuthTokenRefreshRequested', () { blocTest( 'does nothing when state is not AuthAuthenticated', build: () => bloc, act: (b) => b.add(const AuthTokenRefreshRequested()), expect: () => [], verify: (_) => verifyNever(mockAuth.refreshToken()), ); }); // ─── AuthOrgContextInitRequested ───────────────────────────────────────── group('AuthOrgContextInitRequested', () { blocTest( 'sets active organisation via OrgContextService without emitting new state', build: () => bloc, act: (b) => b.add(const AuthOrgContextInitRequested( organisationId: 'org-42', organisationNom: 'Mon Association', type: 'ASSOCIATION', )), expect: () => [], verify: (_) { verify(mockOrgCtx.setActiveOrganisation( organisationId: 'org-42', nom: 'Mon Association', type: 'ASSOCIATION', )).called(1); }, ); blocTest( 'sets active organisation with null type', build: () => bloc, act: (b) => b.add(const AuthOrgContextInitRequested( organisationId: 'org-99', organisationNom: 'Coopérative Test', )), expect: () => [], verify: (_) { verify(mockOrgCtx.setActiveOrganisation( organisationId: 'org-99', nom: 'Coopérative Test', type: null, )).called(1); }, ); }); // ─── AuthState equality ──────────────────────────────────────────────────── group('AuthState equality', () { test('AuthError with same message are equal', () { const s1 = AuthError('some error'); const s2 = AuthError('some error'); expect(s1, equals(s2)); }); test('AuthError with different messages are not equal', () { const s1 = AuthError('error A'); const s2 = AuthError('error B'); expect(s1, isNot(equals(s2))); }); test('AuthPendingOnboarding props are correct', () { const s = AuthPendingOnboarding( onboardingState: 'AWAITING_PAYMENT', souscriptionId: 's-1', organisationId: 'o-1', typeOrganisation: 'ASSOCIATION', ); expect(s.props, containsAll(['AWAITING_PAYMENT', 's-1', 'o-1', 'ASSOCIATION'])); }); test('AuthAccountNotActive props are correct', () { const s = AuthAccountNotActive( statutCompte: 'SUSPENDU', message: 'Votre compte est suspendu.', ); expect(s.props, containsAll(['SUSPENDU', 'Votre compte est suspendu.'])); }); }); // ─── _messageForStatut (via AuthAccountNotActive emitted) ──────────────── group('_messageForStatut messages', () { blocTest( 'SUSPENDU emits the correct suspension message', build: () { final user = _makeUser(); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _blockedStatus('SUSPENDU')); when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [ isA().having( (s) => s.message, 'message', contains('suspendu temporairement'), ), ], ); blocTest( 'DESACTIVE emits the correct deactivation message', build: () { final user = _makeUser(); when(mockAuth.getValidToken()).thenAnswer((_) async => 'token'); when(mockAuth.getCurrentUser()).thenAnswer((_) async => user); when(mockAuth.getAuthStatus(any)) .thenAnswer((_) async => _blockedStatus('DESACTIVE')); when(mockAuth.logout()).thenAnswer((_) async {}); return bloc; }, act: (b) => b.add(const AuthStatusChecked()), expect: () => [ isA().having( (s) => s.message, 'message', contains('désactivé'), ), ], ); }); }