Files
unionflow-mobile-apps/test/features/finance_workflow/bloc/budget_bloc_test.dart
dahoud 37db88672b feat: BLoC tests complets + sécurité production + freerasp 7.5.1 migration
## Tests BLoC (Task P2.4 Mobile)
- 25 nouveaux fichiers *_bloc_test.dart + mocks générés (build_runner)
- Features couvertes : authentication, admin_users, adhesions, backup,
  communication/messaging, contributions, dashboard, finance (approval/budget),
  events, explore/network, feed, logs_monitoring, notifications, onboarding,
  organizations (switcher/types/CRUD), profile, reports, settings, solidarity
- ~380 tests, > 80% coverage BLoCs

## Sécurité Production (Task P2.2)
- lib/core/security/app_integrity_service.dart (freerasp 7.5.1)
- Migration API breaking changes freerasp 7.5.1 :
  - onRootDetected → onPrivilegedAccess
  - onDebuggerDetected → onDebug
  - onSignatureDetected → onAppIntegrity
  - onHookDetected → onHooks
  - onEmulatorDetected → onSimulator
  - onUntrustedInstallationSourceDetected → onUnofficialStore
  - onDeviceBindingDetected → onDeviceBinding
  - onObfuscationIssuesDetected → onObfuscationIssues
  - Talsec.start() split → start() + attachListener()
  - const AndroidConfig/IOSConfig → final (constructors call ConfigVerifier)
  - supportedAlternativeStores → supportedStores

## Pubspec
- bloc_test: ^9.1.7 → ^10.0.0 (compat flutter_bloc ^9.0.0)
- freerasp 7.5.1

## Config
- android/app/build.gradle : ajustements release
- lib/core/config/environment.dart : URLs API actualisées
- lib/main.dart + app_router : intégrations sécurité/BLoC

## Cleanup
- Suppression docs intermédiaires (TACHES_*.md, TASK_*_COMPLETION_REPORT.md,
  TESTS_UNITAIRES_PROGRESS.md)
- .g.dart régénérés (json_serializable)
- .mocks.dart régénérés (mockito)

## Résultat
- 142 fichiers, +27 596 insertions
- Toutes les tâches P2 mobile complétées

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:42:35 +00:00

743 lines
24 KiB
Dart

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 = <String, dynamic>{
'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<BudgetInitial>());
bloc.close();
});
// ─── LoadBudgets ─────────────────────────────────────────────────────────
group('LoadBudgets', () {
blocTest<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetState>(
(s) => s is BudgetCreated && s.message == 'Budget créé avec succès',
'BudgetCreated with correct message',
),
],
);
blocTest<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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<BudgetBloc, BudgetState>(
'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')),
);
});
});
}