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/error/failures.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/domain/entities/budget.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/create_budget.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/get_budget_by_id.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/get_budget_tracking.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/get_budgets.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/presentation/bloc/budget_bloc.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/presentation/bloc/budget_event.dart'; import 'package:unionflow_mobile_apps/features/finance_workflow/presentation/bloc/budget_state.dart'; @GenerateMocks([ GetBudgets, GetBudgetById, CreateBudget, GetBudgetTracking, ]) import 'budget_bloc_test.mocks.dart'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- final _now = DateTime(2026, 4, 20); Budget _buildBudget({ String id = 'budget-1', BudgetStatus status = BudgetStatus.active, String orgId = 'org-1', double totalPlanned = 100000, double totalRealized = 60000, }) => Budget( id: id, name: 'Budget Avril 2026', organizationId: orgId, period: BudgetPeriod.monthly, year: 2026, month: 4, status: status, totalPlanned: totalPlanned, totalRealized: totalRealized, createdBy: 'user-1', createdAt: _now, startDate: DateTime(2026, 4, 1), endDate: DateTime(2026, 4, 30), ); final _budget = _buildBudget(); const _budgetLine = BudgetLine( id: 'line-1', category: BudgetCategory.contributions, name: 'Cotisations membres', amountPlanned: 50000, ); final _tracking = { 'totalPlanned': 100000, 'totalRealized': 60000, 'realizationRate': 60.0, }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- void main() { late MockGetBudgets mockGetBudgets; late MockGetBudgetById mockGetBudgetById; late MockCreateBudget mockCreateBudget; late MockGetBudgetTracking mockGetBudgetTracking; setUp(() { mockGetBudgets = MockGetBudgets(); mockGetBudgetById = MockGetBudgetById(); mockCreateBudget = MockCreateBudget(); mockGetBudgetTracking = MockGetBudgetTracking(); }); BudgetBloc buildBloc() => BudgetBloc( getBudgets: mockGetBudgets, getBudgetById: mockGetBudgetById, createBudget: mockCreateBudget, getBudgetTracking: mockGetBudgetTracking, ); // ─── initial state ─────────────────────────────────────────────────────── test('initial state is BudgetInitial', () { final bloc = buildBloc(); expect(bloc.state, isA()); bloc.close(); }); // ─── LoadBudgets ───────────────────────────────────────────────────────── group('LoadBudgets', () { blocTest( 'emits [BudgetsLoading, BudgetsLoaded] on success', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => Right([_budget])); return buildBloc(); }, act: (b) => b.add( const LoadBudgets(organizationId: 'org-1'), ), expect: () => [ const BudgetsLoading(), BudgetsLoaded(budgets: [_budget]), ], verify: (_) { verify(mockGetBudgets( organizationId: 'org-1', status: null, year: null, )).called(1); }, ); blocTest( 'emits [BudgetsLoading, BudgetsEmpty] when list is empty', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => const Right([])); return buildBloc(); }, act: (b) => b.add(const LoadBudgets()), expect: () => [ const BudgetsLoading(), const BudgetsEmpty(), ], ); blocTest( 'emits [BudgetsLoading, BudgetError] on failure', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => const Left(ServerFailure('server error'))); return buildBloc(); }, act: (b) => b.add(const LoadBudgets(organizationId: 'org-1')), expect: () => [ const BudgetsLoading(), const BudgetError('server error'), ], ); blocTest( 'passes status and year filters to use case', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => Right([_budget])); return buildBloc(); }, act: (b) => b.add( const LoadBudgets( organizationId: 'org-1', status: BudgetStatus.active, year: 2026, ), ), verify: (_) { verify(mockGetBudgets( organizationId: 'org-1', status: BudgetStatus.active, year: 2026, )).called(1); }, ); blocTest( 'BudgetsLoaded preserves filterStatus and filterYear', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => Right([_budget])); return buildBloc(); }, act: (b) => b.add( const LoadBudgets( organizationId: 'org-1', status: BudgetStatus.draft, year: 2025, ), ), expect: () => [ const BudgetsLoading(), BudgetsLoaded( budgets: [_budget], filterStatus: BudgetStatus.draft, filterYear: 2025, ), ], ); }); // ─── LoadBudgetById ─────────────────────────────────────────────────────── group('LoadBudgetById', () { blocTest( 'emits [BudgetsLoading, BudgetDetailLoaded] on success', build: () { when(mockGetBudgetById('budget-1')) .thenAnswer((_) async => Right(_budget)); return buildBloc(); }, act: (b) => b.add(const LoadBudgetById('budget-1')), expect: () => [ const BudgetsLoading(), BudgetDetailLoaded(_budget), ], verify: (_) { verify(mockGetBudgetById('budget-1')).called(1); }, ); blocTest( 'emits [BudgetsLoading, BudgetError] when not found', build: () { when(mockGetBudgetById(any)) .thenAnswer((_) async => const Left(NotFoundFailure('Budget introuvable'))); return buildBloc(); }, act: (b) => b.add(const LoadBudgetById('unknown')), expect: () => [ const BudgetsLoading(), const BudgetError('Budget introuvable'), ], ); blocTest( 'emits [BudgetsLoading, BudgetError] on network failure', build: () { when(mockGetBudgetById(any)) .thenAnswer((_) async => const Left(NetworkFailure('net'))); return buildBloc(); }, act: (b) => b.add(const LoadBudgetById('budget-1')), expect: () => [ const BudgetsLoading(), const BudgetError('net'), ], ); }); // ─── CreateBudgetEvent ──────────────────────────────────────────────────── group('CreateBudgetEvent', () { blocTest( 'emits [BudgetActionInProgress(create), BudgetCreated] on success', build: () { when(mockCreateBudget( name: anyNamed('name'), description: anyNamed('description'), organizationId: anyNamed('organizationId'), period: anyNamed('period'), year: anyNamed('year'), month: anyNamed('month'), lines: anyNamed('lines'), )).thenAnswer((_) async => Right(_budget)); return buildBloc(); }, act: (b) => b.add( const CreateBudgetEvent( name: 'Budget Avril 2026', organizationId: 'org-1', period: BudgetPeriod.monthly, year: 2026, month: 4, lines: [_budgetLine], ), ), expect: () => [ const BudgetActionInProgress('create'), BudgetCreated(budget: _budget), ], verify: (_) { verify(mockCreateBudget( name: 'Budget Avril 2026', description: null, organizationId: 'org-1', period: BudgetPeriod.monthly, year: 2026, month: 4, lines: const [_budgetLine], )).called(1); }, ); blocTest( 'BudgetCreated has correct default message', build: () { when(mockCreateBudget( name: anyNamed('name'), description: anyNamed('description'), organizationId: anyNamed('organizationId'), period: anyNamed('period'), year: anyNamed('year'), month: anyNamed('month'), lines: anyNamed('lines'), )).thenAnswer((_) async => Right(_budget)); return buildBloc(); }, act: (b) => b.add( const CreateBudgetEvent( name: 'Budget', organizationId: 'org-1', period: BudgetPeriod.annual, year: 2026, lines: [_budgetLine], ), ), expect: () => [ const BudgetActionInProgress('create'), predicate( (s) => s is BudgetCreated && s.message == 'Budget créé avec succès', 'BudgetCreated with correct message', ), ], ); blocTest( 'emits [BudgetActionInProgress(create), BudgetError] on validation failure', build: () { when(mockCreateBudget( name: anyNamed('name'), description: anyNamed('description'), organizationId: anyNamed('organizationId'), period: anyNamed('period'), year: anyNamed('year'), month: anyNamed('month'), lines: anyNamed('lines'), )).thenAnswer((_) async => const Left(ValidationFailure('Nom du budget requis'))); return buildBloc(); }, act: (b) => b.add( const CreateBudgetEvent( name: '', organizationId: 'org-1', period: BudgetPeriod.monthly, year: 2026, month: 4, lines: [_budgetLine], ), ), expect: () => [ const BudgetActionInProgress('create'), const BudgetError('Nom du budget requis'), ], ); blocTest( 'emits [BudgetActionInProgress(create), BudgetError] on server failure', build: () { when(mockCreateBudget( name: anyNamed('name'), description: anyNamed('description'), organizationId: anyNamed('organizationId'), period: anyNamed('period'), year: anyNamed('year'), month: anyNamed('month'), lines: anyNamed('lines'), )).thenAnswer((_) async => const Left(ServerFailure('server error'))); return buildBloc(); }, act: (b) => b.add( const CreateBudgetEvent( name: 'Budget valide', organizationId: 'org-1', period: BudgetPeriod.annual, year: 2026, lines: [_budgetLine], ), ), expect: () => [ const BudgetActionInProgress('create'), const BudgetError('server error'), ], ); }); // ─── LoadBudgetTracking ─────────────────────────────────────────────────── group('LoadBudgetTracking', () { blocTest( 'emits [BudgetsLoading, BudgetTrackingLoaded] on success', build: () { when(mockGetBudgetById('budget-1')) .thenAnswer((_) async => Right(_budget)); when(mockGetBudgetTracking(budgetId: 'budget-1')) .thenAnswer((_) async => Right(_tracking)); return buildBloc(); }, act: (b) => b.add(const LoadBudgetTracking('budget-1')), expect: () => [ const BudgetsLoading(), BudgetTrackingLoaded(budget: _budget, tracking: _tracking), ], verify: (_) { verify(mockGetBudgetById('budget-1')).called(1); verify(mockGetBudgetTracking(budgetId: 'budget-1')).called(1); }, ); blocTest( 'emits [BudgetsLoading, BudgetError] when getBudgetById fails', build: () { when(mockGetBudgetById(any)) .thenAnswer((_) async => const Left(NotFoundFailure('Budget introuvable'))); return buildBloc(); }, act: (b) => b.add(const LoadBudgetTracking('unknown')), expect: () => [ const BudgetsLoading(), const BudgetError('Budget introuvable'), ], verify: (_) { // Tracking should NOT be called if budget fetch fails verifyNever(mockGetBudgetTracking(budgetId: anyNamed('budgetId'))); }, ); blocTest( 'emits [BudgetsLoading, BudgetError] when getTracking fails', build: () { when(mockGetBudgetById('budget-1')) .thenAnswer((_) async => Right(_budget)); when(mockGetBudgetTracking(budgetId: anyNamed('budgetId'))) .thenAnswer((_) async => const Left(ServerFailure('tracking error'))); return buildBloc(); }, act: (b) => b.add(const LoadBudgetTracking('budget-1')), expect: () => [ const BudgetsLoading(), const BudgetError('tracking error'), ], ); }); // ─── RefreshBudgets ─────────────────────────────────────────────────────── group('RefreshBudgets', () { blocTest( 'emits BudgetsLoaded without BudgetsLoading during refresh', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => Right([_budget])); return buildBloc(); }, act: (b) => b.add(const RefreshBudgets(organizationId: 'org-1')), // RefreshBudgets does NOT emit BudgetsLoading — goes straight to result expect: () => [ BudgetsLoaded(budgets: [_budget]), ], ); blocTest( 'emits BudgetsEmpty on refresh when list is empty', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => const Right([])); return buildBloc(); }, act: (b) => b.add(const RefreshBudgets()), expect: () => [const BudgetsEmpty()], ); blocTest( 'emits BudgetError on refresh failure', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => const Left(ServerFailure('refresh error'))); return buildBloc(); }, act: (b) => b.add(const RefreshBudgets(organizationId: 'org-1')), expect: () => [const BudgetError('refresh error')], ); }); // ─── FilterBudgets ──────────────────────────────────────────────────────── group('FilterBudgets', () { final activeBudget = _buildBudget(id: 'budget-active', status: BudgetStatus.active); final draftBudget = _buildBudget(id: 'budget-draft', status: BudgetStatus.draft); blocTest( 'emits [BudgetsLoading, BudgetsLoaded] with filter applied', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: BudgetStatus.active, year: anyNamed('year'), )).thenAnswer((_) async => Right([activeBudget])); return buildBloc(); }, act: (b) => b.add( const FilterBudgets(status: BudgetStatus.active), ), expect: () => [ const BudgetsLoading(), BudgetsLoaded( budgets: [activeBudget], filterStatus: BudgetStatus.active, ), ], verify: (_) { verify(mockGetBudgets( organizationId: null, // no loaded state, so null status: BudgetStatus.active, year: null, )).called(1); }, ); blocTest( 'uses organizationId from loaded state for filter', build: () { // First call (LoadBudgets) returns activeBudget with orgId='org-1' when(mockGetBudgets( organizationId: 'org-1', status: null, year: null, )).thenAnswer((_) async => Right([activeBudget])); // Second call (FilterBudgets) returns draftBudget when(mockGetBudgets( organizationId: 'org-1', status: BudgetStatus.draft, year: null, )).thenAnswer((_) async => Right([draftBudget])); return buildBloc(); }, act: (b) async { b.add(const LoadBudgets(organizationId: 'org-1')); // Wait until BudgetsLoaded so FilterBudgets sees org-1 in state await b.stream.firstWhere((s) => s is BudgetsLoaded); b.add(const FilterBudgets(status: BudgetStatus.draft)); }, expect: () => [ const BudgetsLoading(), BudgetsLoaded(budgets: [activeBudget]), const BudgetsLoading(), BudgetsLoaded( budgets: [draftBudget], filterStatus: BudgetStatus.draft, ), ], verify: (_) { // organizationId extracted from first budget in loaded state verify(mockGetBudgets( organizationId: 'org-1', status: BudgetStatus.draft, year: null, )).called(1); }, ); blocTest( 'emits [BudgetsLoading, BudgetsEmpty] when filter returns no results', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => const Right([])); return buildBloc(); }, act: (b) => b.add(const FilterBudgets(status: BudgetStatus.cancelled)), expect: () => [ const BudgetsLoading(), const BudgetsEmpty(), ], ); blocTest( 'emits [BudgetsLoading, BudgetError] on filter failure', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => const Left(ServerFailure('filter error'))); return buildBloc(); }, act: (b) => b.add(const FilterBudgets(year: 2025)), expect: () => [ const BudgetsLoading(), const BudgetError('filter error'), ], ); blocTest( 'filters with year only', build: () { when(mockGetBudgets( organizationId: anyNamed('organizationId'), status: anyNamed('status'), year: anyNamed('year'), )).thenAnswer((_) async => Right([_budget])); return buildBloc(); }, act: (b) => b.add(const FilterBudgets(year: 2026)), verify: (_) { verify(mockGetBudgets( organizationId: null, status: null, year: 2026, )).called(1); }, ); }); // ─── State equality / Budget entity ────────────────────────────────────── group('Budget entity computations', () { test('realizationRate is correct', () { expect(_budget.realizationRate, closeTo(60.0, 0.01)); }); test('variance is correct', () { expect(_budget.variance, closeTo(-40000, 0.01)); }); test('isOverBudget is false when realized < planned', () { expect(_budget.isOverBudget, isFalse); }); test('isOverBudget is true when realized > planned', () { final over = _buildBudget(totalPlanned: 100000, totalRealized: 120000); expect(over.isOverBudget, isTrue); }); test('isActive is true for active budget', () { expect(_budget.isActive, isTrue); }); test('realizationRate is 0 when totalPlanned is 0', () { final zeroBudget = _buildBudget(totalPlanned: 0, totalRealized: 0); expect(zeroBudget.realizationRate, equals(0.0)); }); test('varianceRate is 0 when totalPlanned is 0', () { final zeroBudget = _buildBudget(totalPlanned: 0, totalRealized: 0); expect(zeroBudget.varianceRate, equals(0.0)); }); }); group('BudgetLine entity computations', () { test('realizationRate is correct', () { const line = BudgetLine( id: 'l', category: BudgetCategory.events, name: 'Test', amountPlanned: 10000, amountRealized: 7500, ); expect(line.realizationRate, closeTo(75.0, 0.01)); }); test('isOverBudget is false when realized <= planned', () { expect(_budgetLine.isOverBudget, isFalse); }); test('realizationRate is 0 when amountPlanned is 0', () { const line = BudgetLine( id: 'l', category: BudgetCategory.other, name: 'Zero', amountPlanned: 0, ); expect(line.realizationRate, equals(0.0)); }); }); group('State equality', () { test('BudgetsLoaded equality', () { final s1 = BudgetsLoaded(budgets: [_budget]); final s2 = BudgetsLoaded(budgets: [_budget]); expect(s1, equals(s2)); }); test('BudgetError equality', () { const e1 = BudgetError('msg'); const e2 = BudgetError('msg'); expect(e1, equals(e2)); }); test('BudgetsEmpty has default message', () { const s = BudgetsEmpty(); expect(s.message, 'Aucun budget trouvé'); }); test('BudgetActionInProgress equality', () { expect( const BudgetActionInProgress('create'), equals(const BudgetActionInProgress('create')), ); }); }); }