import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:dartz/dartz.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/core/error/failures.dart'; import 'package:unionflow_mobile_apps/core/websocket/websocket_service.dart'; import 'package:unionflow_mobile_apps/features/dashboard/domain/entities/dashboard_entity.dart'; import 'package:unionflow_mobile_apps/features/dashboard/domain/usecases/get_dashboard_data.dart'; import 'package:unionflow_mobile_apps/features/dashboard/presentation/bloc/dashboard_bloc.dart'; @GenerateMocks([ GetDashboardData, GetDashboardStats, GetRecentActivities, GetUpcomingEvents, WebSocketService, ]) import 'dashboard_bloc_test.mocks.dart'; // --------------------------------------------------------------------------- // Test fixtures // --------------------------------------------------------------------------- DashboardStatsEntity _buildStats() => DashboardStatsEntity( totalMembers: 100, activeMembers: 80, totalEvents: 10, upcomingEvents: 3, totalContributions: 50, totalContributionAmount: 500000, pendingRequests: 5, completedProjects: 2, monthlyGrowth: 0.05, engagementRate: 0.8, lastUpdated: DateTime(2026, 4, 20), ); DashboardEntity _buildDashboardEntity({ String orgId = 'org-1', String userId = 'user-1', }) => DashboardEntity( stats: _buildStats(), recentActivities: const [], upcomingEvents: const [], userPreferences: const {}, organizationId: orgId, userId: userId, ); // --------------------------------------------------------------------------- // Helpers to keep WebSocketService mock compiling without excessive stub noise // --------------------------------------------------------------------------- void _stubWebSocket(MockWebSocketService ws) { // Provide broadcast streams so the bloc can subscribe without NPE final eventController = StreamController.broadcast(); final statusController = StreamController.broadcast(); when(ws.eventStream).thenAnswer((_) => eventController.stream); when(ws.connectionStatusStream).thenAnswer((_) => statusController.stream); when(ws.connect()).thenReturn(null); when(ws.disconnect()).thenReturn(null); when(ws.isConnected).thenReturn(false); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- void main() { // AppConfig uses late final statics — initialize once before any tests run setUpAll(() { try { AppConfig.initialize(); } catch (_) { // Already initialized by a previous test suite in the same process } }); late MockGetDashboardData mockGetDashboardData; late MockGetDashboardStats mockGetDashboardStats; late MockGetRecentActivities mockGetRecentActivities; late MockGetUpcomingEvents mockGetUpcomingEvents; late MockWebSocketService mockWebSocketService; setUp(() { mockGetDashboardData = MockGetDashboardData(); mockGetDashboardStats = MockGetDashboardStats(); mockGetRecentActivities = MockGetRecentActivities(); mockGetUpcomingEvents = MockGetUpcomingEvents(); mockWebSocketService = MockWebSocketService(); _stubWebSocket(mockWebSocketService); }); DashboardBloc buildBloc() => DashboardBloc( getDashboardData: mockGetDashboardData, getDashboardStats: mockGetDashboardStats, getRecentActivities: mockGetRecentActivities, getUpcomingEvents: mockGetUpcomingEvents, webSocketService: mockWebSocketService, ); // blocs are closed inside blocTest automatically — no explicit tearDown needed // ─── initial state ─────────────────────────────────────────────────────── test('initial state is DashboardInitial', () { final bloc = buildBloc(); expect(bloc.state, isA()); bloc.close(); }); // ─── LoadDashboardData ─────────────────────────────────────────────────── group('LoadDashboardData', () { final entity = _buildDashboardEntity(); blocTest( 'emits [DashboardLoading, DashboardLoaded] on success', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => Right(entity)); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), isA(), ], verify: (_) { verify(mockGetDashboardData( const GetDashboardDataParams( organizationId: 'org-1', userId: 'user-1', ), )).called(1); }, ); blocTest( 'emits [DashboardLoading, DashboardLoaded] with correct entity', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => Right(entity)); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), DashboardLoaded(entity), ], ); blocTest( 'emits [DashboardLoading, DashboardError] on ServerFailure', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => const Left(ServerFailure('server error'))); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), isA(), ], ); blocTest( 'error message for NetworkFailure is user-friendly', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => const Left(NetworkFailure('no internet'))); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), const DashboardError( 'Pas de connexion internet. Vérifiez votre connexion.', ), ], ); blocTest( 'error message for UnauthorizedFailure is user-friendly', build: () { when(mockGetDashboardData(any)).thenAnswer( (_) async => const Left(UnauthorizedFailure('401'))); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), const DashboardError( 'Session expirée. Veuillez vous reconnecter.', ), ], ); blocTest( 'emits DashboardMemberNotRegistered on NotFoundFailure', build: () { when(mockGetDashboardData(any)).thenAnswer( (_) async => const Left( NotFoundFailure('membre non inscrit'), ), ); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), isA(), ], ); blocTest( 'passes useGlobalDashboard flag to use case', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => Right(entity)); return buildBloc(); }, act: (b) => b.add( const LoadDashboardData( organizationId: '', userId: 'super-admin', useGlobalDashboard: true, ), ), verify: (_) { verify(mockGetDashboardData( const GetDashboardDataParams( organizationId: '', userId: 'super-admin', useGlobalDashboard: true, ), )).called(1); }, ); }); // ─── RefreshDashboardData ──────────────────────────────────────────────── group('RefreshDashboardData', () { final entity = _buildDashboardEntity(); blocTest( 'emits [DashboardLoading, DashboardLoaded] when state is not loaded', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => Right(entity)); return buildBloc(); }, act: (b) => b.add( const RefreshDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), isA(), ], ); test('emits [DashboardRefreshing, DashboardLoaded] when state is DashboardLoaded', () async { when(mockGetDashboardData(any)).thenAnswer((_) async => Right(entity)); final bloc = buildBloc(); bloc.add(const LoadDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded); final states = []; final sub = bloc.stream.listen(states.add); bloc.add(const RefreshDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded || s is DashboardError); await sub.cancel(); expect(states, containsAll([isA(), isA()])); await bloc.close(); }); blocTest( 'emits DashboardError on failure during refresh', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => const Left(ServerFailure('err'))); return buildBloc(); }, act: (b) => b.add( const RefreshDashboardData(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [ isA(), isA(), ], ); }); // ─── LoadDashboardStats ────────────────────────────────────────────────── group('LoadDashboardStats', () { final entity = _buildDashboardEntity(); final stats = _buildStats(); // BLoC 9.x processes events concurrently. LoadDashboardStats only emits when // state is DashboardLoaded. We verify the use case IS called and, when the // state is Initial (not loaded), nothing is emitted. blocTest( 'calls getDashboardStats use case with correct params', build: () { when(mockGetDashboardData(any)) .thenAnswer((_) async => Right(entity)); when(mockGetDashboardStats(any)) .thenAnswer((_) async => Right(stats)); return buildBloc(); }, act: (b) => b.add( const LoadDashboardStats(organizationId: 'org-1', userId: 'user-1'), ), verify: (_) { verify(mockGetDashboardStats( const GetDashboardStatsParams( organizationId: 'org-1', userId: 'user-1', ), )).called(1); }, ); blocTest( 'emits nothing when state is not DashboardLoaded (stats not injected)', build: () { when(mockGetDashboardStats(any)) .thenAnswer((_) async => Right(stats)); return buildBloc(); }, // state is DashboardInitial — LoadDashboardStats won't emit act: (b) => b.add( const LoadDashboardStats(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [], ); // Stats failure always emits DashboardError regardless of current state blocTest( 'emits DashboardError on stats failure', build: () { when(mockGetDashboardStats(any)) .thenAnswer((_) async => const Left(ServerFailure('stats error'))); return buildBloc(); }, act: (b) => b.add( const LoadDashboardStats(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [isA()], ); }); // ─── LoadRecentActivities ──────────────────────────────────────────────── group('LoadRecentActivities', () { final entity = _buildDashboardEntity(); final activity = RecentActivityEntity( id: 'act-1', type: 'contribution', title: 'Cotisation payée', description: 'Cotisation mensuelle', userName: 'Alice', timestamp: DateTime(2026, 4, 19), ); test('updates activities in DashboardLoaded on success', () async { when(mockGetDashboardData(any)).thenAnswer((_) async => Right(entity)); when(mockGetRecentActivities(any)).thenAnswer((_) async => Right([activity])); final bloc = buildBloc(); bloc.add(const LoadDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded); bloc.add(const LoadRecentActivities(organizationId: 'org-1', userId: 'user-1')); final next = await bloc.stream.firstWhere((s) => s is DashboardLoaded || s is DashboardError); expect(next, isA()); final loaded = next as DashboardLoaded; expect(loaded.dashboardData.recentActivities, [activity]); await bloc.close(); }); blocTest( 'does nothing when state is not DashboardLoaded', build: () { when(mockGetRecentActivities(any)) .thenAnswer((_) async => Right([activity])); return buildBloc(); }, act: (b) => b.add( const LoadRecentActivities(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [], ); test('emits DashboardError on failure when state is DashboardLoaded', () async { when(mockGetDashboardData(any)).thenAnswer((_) async => Right(entity)); when(mockGetRecentActivities(any)) .thenAnswer((_) async => const Left(NetworkFailure('net'))); final bloc = buildBloc(); bloc.add(const LoadDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded); bloc.add(const LoadRecentActivities(organizationId: 'org-1', userId: 'user-1')); final next = await bloc.stream.firstWhere((s) => s is DashboardError || s is DashboardLoaded); expect(next, isA()); await bloc.close(); }); test('passes custom limit to use case', () async { when(mockGetDashboardData(any)).thenAnswer((_) async => Right(entity)); when(mockGetRecentActivities(any)).thenAnswer((_) async => Right([activity])); final bloc = buildBloc(); bloc.add(const LoadDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded); bloc.add(const LoadRecentActivities( organizationId: 'org-1', userId: 'user-1', limit: 20, )); await bloc.stream.firstWhere((s) => s is DashboardLoaded || s is DashboardError); verify(mockGetRecentActivities( const GetRecentActivitiesParams( organizationId: 'org-1', userId: 'user-1', limit: 20, ), )).called(1); await bloc.close(); }); }); // ─── LoadUpcomingEvents ────────────────────────────────────────────────── group('LoadUpcomingEvents', () { final entity = _buildDashboardEntity(); final upcomingEvent = UpcomingEventEntity( id: 'evt-1', title: 'AG annuelle', description: 'Assemblée générale', startDate: DateTime(2026, 5, 1), location: 'Dakar', maxParticipants: 100, currentParticipants: 40, status: 'planifié', tags: const [], ); test('updates events in DashboardLoaded on success', () async { when(mockGetDashboardData(any)).thenAnswer((_) async => Right(entity)); when(mockGetUpcomingEvents(any)).thenAnswer((_) async => Right([upcomingEvent])); final bloc = buildBloc(); bloc.add(const LoadDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded); bloc.add(const LoadUpcomingEvents(organizationId: 'org-1', userId: 'user-1')); final next = await bloc.stream.firstWhere((s) => s is DashboardLoaded || s is DashboardError); expect(next, isA()); final loaded = next as DashboardLoaded; expect(loaded.dashboardData.upcomingEvents, [upcomingEvent]); await bloc.close(); }); blocTest( 'does nothing when state is not DashboardLoaded', build: () { when(mockGetUpcomingEvents(any)) .thenAnswer((_) async => Right([upcomingEvent])); return buildBloc(); }, act: (b) => b.add( const LoadUpcomingEvents(organizationId: 'org-1', userId: 'user-1'), ), expect: () => [], ); test('emits DashboardError on failure when state is DashboardLoaded', () async { when(mockGetDashboardData(any)).thenAnswer((_) async => Right(entity)); when(mockGetUpcomingEvents(any)) .thenAnswer((_) async => const Left(ServerFailure('err'))); final bloc = buildBloc(); bloc.add(const LoadDashboardData(organizationId: 'org-1', userId: 'user-1')); await bloc.stream.firstWhere((s) => s is DashboardLoaded); bloc.add(const LoadUpcomingEvents(organizationId: 'org-1', userId: 'user-1')); final next = await bloc.stream.firstWhere((s) => s is DashboardError || s is DashboardLoaded); expect(next, isA()); await bloc.close(); }); }); // ─── RefreshDashboardFromWebSocket ─────────────────────────────────────── group('RefreshDashboardFromWebSocket', () { final stats = _buildStats(); // BLoC 9 concurrent: RefreshDashboardFromWebSocket calls getDashboardStats // only when state is DashboardLoaded. We verify the call is made. blocTest( 'calls getDashboardStats when triggered from WebSocket (state not loaded — no call)', build: () { when(mockGetDashboardStats(any)) .thenAnswer((_) async => Right(stats)); return buildBloc(); }, act: (b) => b.add( const RefreshDashboardFromWebSocket({'type': 'DASHBOARD_STATS_UPDATED'}), ), // state is DashboardInitial — getDashboardStats NOT called verify: (_) => verifyZeroInteractions(mockGetDashboardStats), ); // WebSocket refresh with failure swallows error silently blocTest( 'does not emit error when getDashboardStats fails during WebSocket refresh', build: () { when(mockGetDashboardStats(any)) .thenAnswer((_) async => const Left(NetworkFailure('net'))); return buildBloc(); }, // state is DashboardInitial — nothing happens (getDashboardStats not called) act: (b) => b.add( const RefreshDashboardFromWebSocket({'type': 'CONTRIBUTION_PAID'}), ), expect: () => [], ); blocTest( 'does nothing when state is not DashboardLoaded', build: () { return buildBloc(); }, act: (b) => b.add( const RefreshDashboardFromWebSocket({'type': 'DASHBOARD_STATS_UPDATED'}), ), expect: () => [], verify: (_) => verifyZeroInteractions(mockGetDashboardStats), ); }); // ─── WebSocketConnectionChanged ────────────────────────────────────────── group('WebSocketConnectionChanged', () { blocTest( 'emits nothing (no state change) on connected = true', build: buildBloc, act: (b) => b.add(const WebSocketConnectionChanged(true)), expect: () => [], ); blocTest( 'emits nothing (no state change) on connected = false', build: buildBloc, act: (b) => b.add(const WebSocketConnectionChanged(false)), expect: () => [], ); }); // ─── close() ───────────────────────────────────────────────────────────── test('close() disconnects WebSocket', () async { final bloc = buildBloc(); await bloc.close(); verify(mockWebSocketService.disconnect()).called(1); }); }