## 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>
484 lines
16 KiB
Dart
484 lines
16 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/transaction_approval.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/approve_transaction.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/get_approval_by_id.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/get_pending_approvals.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/domain/usecases/reject_transaction.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/presentation/bloc/approval_bloc.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/presentation/bloc/approval_event.dart';
|
|
import 'package:unionflow_mobile_apps/features/finance_workflow/presentation/bloc/approval_state.dart';
|
|
|
|
@GenerateMocks([
|
|
GetPendingApprovals,
|
|
GetApprovalById,
|
|
ApproveTransaction,
|
|
RejectTransaction,
|
|
])
|
|
import 'approval_bloc_test.mocks.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TransactionApproval _buildApproval({
|
|
String id = 'appr-1',
|
|
ApprovalStatus status = ApprovalStatus.pending,
|
|
}) =>
|
|
TransactionApproval(
|
|
id: id,
|
|
transactionId: 'tx-1',
|
|
transactionType: TransactionType.contribution,
|
|
amount: 50000,
|
|
requesterId: 'user-1',
|
|
requesterName: 'Alice Diallo',
|
|
requiredLevel: ApprovalLevel.level1,
|
|
status: status,
|
|
createdAt: DateTime(2026, 4, 1),
|
|
);
|
|
|
|
final _pendingApproval = _buildApproval();
|
|
final _approvedApproval =
|
|
_buildApproval(status: ApprovalStatus.approved);
|
|
final _rejectedApproval =
|
|
_buildApproval(status: ApprovalStatus.rejected);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void main() {
|
|
late MockGetPendingApprovals mockGetPendingApprovals;
|
|
late MockGetApprovalById mockGetApprovalById;
|
|
late MockApproveTransaction mockApproveTransaction;
|
|
late MockRejectTransaction mockRejectTransaction;
|
|
|
|
setUp(() {
|
|
mockGetPendingApprovals = MockGetPendingApprovals();
|
|
mockGetApprovalById = MockGetApprovalById();
|
|
mockApproveTransaction = MockApproveTransaction();
|
|
mockRejectTransaction = MockRejectTransaction();
|
|
});
|
|
|
|
ApprovalBloc buildBloc() => ApprovalBloc(
|
|
getPendingApprovals: mockGetPendingApprovals,
|
|
getApprovalById: mockGetApprovalById,
|
|
approveTransaction: mockApproveTransaction,
|
|
rejectTransaction: mockRejectTransaction,
|
|
);
|
|
|
|
// ─── initial state ───────────────────────────────────────────────────────
|
|
|
|
test('initial state is ApprovalInitial', () {
|
|
final bloc = buildBloc();
|
|
expect(bloc.state, isA<ApprovalInitial>());
|
|
bloc.close();
|
|
});
|
|
|
|
// ─── LoadPendingApprovals ────────────────────────────────────────────────
|
|
|
|
group('LoadPendingApprovals', () {
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalsLoaded] when approvals exist',
|
|
build: () {
|
|
when(mockGetPendingApprovals(organizationId: anyNamed('organizationId')))
|
|
.thenAnswer((_) async => Right([_pendingApproval]));
|
|
return buildBloc();
|
|
},
|
|
act: (b) =>
|
|
b.add(const LoadPendingApprovals(organizationId: 'org-1')),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
ApprovalsLoaded(
|
|
approvals: [_pendingApproval],
|
|
pendingCount: 1,
|
|
),
|
|
],
|
|
verify: (_) {
|
|
verify(mockGetPendingApprovals(organizationId: 'org-1')).called(1);
|
|
},
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalsLoaded] with correct pendingCount',
|
|
build: () {
|
|
when(mockGetPendingApprovals(organizationId: anyNamed('organizationId')))
|
|
.thenAnswer((_) async => Right([
|
|
_pendingApproval,
|
|
_buildApproval(id: 'appr-2'),
|
|
_buildApproval(id: 'appr-3'),
|
|
]));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadPendingApprovals()),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
predicate<ApprovalState>(
|
|
(s) => s is ApprovalsLoaded && s.pendingCount == 3,
|
|
'ApprovalsLoaded with pendingCount == 3',
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalsEmpty] when list is empty',
|
|
build: () {
|
|
when(mockGetPendingApprovals(organizationId: anyNamed('organizationId')))
|
|
.thenAnswer((_) async => const Right([]));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadPendingApprovals(organizationId: 'org-1')),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
const ApprovalsEmpty(),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalError] on failure',
|
|
build: () {
|
|
when(mockGetPendingApprovals(organizationId: anyNamed('organizationId')))
|
|
.thenAnswer((_) async =>
|
|
const Left(ServerFailure('server error')));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadPendingApprovals(organizationId: 'org-1')),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
const ApprovalError('server error'),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'works without organizationId (null)',
|
|
build: () {
|
|
when(mockGetPendingApprovals(organizationId: null))
|
|
.thenAnswer((_) async => Right([_pendingApproval]));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadPendingApprovals()),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
isA<ApprovalsLoaded>(),
|
|
],
|
|
);
|
|
});
|
|
|
|
// ─── LoadApprovalById ────────────────────────────────────────────────────
|
|
|
|
group('LoadApprovalById', () {
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalDetailLoaded] on success',
|
|
build: () {
|
|
when(mockGetApprovalById('appr-1'))
|
|
.thenAnswer((_) async => Right(_pendingApproval));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadApprovalById('appr-1')),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
ApprovalDetailLoaded(_pendingApproval),
|
|
],
|
|
verify: (_) {
|
|
verify(mockGetApprovalById('appr-1')).called(1);
|
|
},
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalError] when not found',
|
|
build: () {
|
|
when(mockGetApprovalById(any))
|
|
.thenAnswer((_) async =>
|
|
const Left(NotFoundFailure('Approbation introuvable')));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadApprovalById('unknown-id')),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
const ApprovalError('Approbation introuvable'),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalsLoading, ApprovalError] on network failure',
|
|
build: () {
|
|
when(mockGetApprovalById(any))
|
|
.thenAnswer((_) async =>
|
|
const Left(NetworkFailure('net')));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const LoadApprovalById('appr-1')),
|
|
expect: () => [
|
|
const ApprovalsLoading(),
|
|
const ApprovalError('net'),
|
|
],
|
|
);
|
|
});
|
|
|
|
// ─── ApproveTransactionEvent ─────────────────────────────────────────────
|
|
|
|
group('ApproveTransactionEvent', () {
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalActionInProgress(approve), TransactionApproved] on success',
|
|
build: () {
|
|
when(mockApproveTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
comment: anyNamed('comment'),
|
|
)).thenAnswer((_) async => Right(_approvedApproval));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(
|
|
const ApproveTransactionEvent(
|
|
approvalId: 'appr-1',
|
|
comment: 'OK pour moi',
|
|
),
|
|
),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('approve'),
|
|
TransactionApproved(approval: _approvedApproval),
|
|
],
|
|
verify: (_) {
|
|
verify(mockApproveTransaction(
|
|
approvalId: 'appr-1',
|
|
comment: 'OK pour moi',
|
|
)).called(1);
|
|
},
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalActionInProgress(approve), TransactionApproved] without comment',
|
|
build: () {
|
|
when(mockApproveTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
comment: anyNamed('comment'),
|
|
)).thenAnswer((_) async => Right(_approvedApproval));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(
|
|
const ApproveTransactionEvent(approvalId: 'appr-1'),
|
|
),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('approve'),
|
|
isA<TransactionApproved>(),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalActionInProgress(approve), ApprovalError] on failure',
|
|
build: () {
|
|
when(mockApproveTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
comment: anyNamed('comment'),
|
|
)).thenAnswer(
|
|
(_) async => const Left(ForbiddenFailure('Droits insuffisants')));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(
|
|
const ApproveTransactionEvent(approvalId: 'appr-1'),
|
|
),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('approve'),
|
|
const ApprovalError('Droits insuffisants'),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'TransactionApproved has correct default message',
|
|
build: () {
|
|
when(mockApproveTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
comment: anyNamed('comment'),
|
|
)).thenAnswer((_) async => Right(_approvedApproval));
|
|
return buildBloc();
|
|
},
|
|
act: (b) =>
|
|
b.add(const ApproveTransactionEvent(approvalId: 'appr-1')),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('approve'),
|
|
predicate<ApprovalState>(
|
|
(s) =>
|
|
s is TransactionApproved &&
|
|
s.message == 'Transaction approuvée avec succès',
|
|
'TransactionApproved with correct message',
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
// ─── RejectTransactionEvent ──────────────────────────────────────────────
|
|
|
|
group('RejectTransactionEvent', () {
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalActionInProgress(reject), TransactionRejected] on success',
|
|
build: () {
|
|
when(mockRejectTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
reason: anyNamed('reason'),
|
|
)).thenAnswer((_) async => Right(_rejectedApproval));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(
|
|
const RejectTransactionEvent(
|
|
approvalId: 'appr-1',
|
|
reason: 'Montant incorrect',
|
|
),
|
|
),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('reject'),
|
|
TransactionRejected(approval: _rejectedApproval),
|
|
],
|
|
verify: (_) {
|
|
verify(mockRejectTransaction(
|
|
approvalId: 'appr-1',
|
|
reason: 'Montant incorrect',
|
|
)).called(1);
|
|
},
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits [ApprovalActionInProgress(reject), ApprovalError] on failure',
|
|
build: () {
|
|
when(mockRejectTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
reason: anyNamed('reason'),
|
|
)).thenAnswer((_) async =>
|
|
const Left(ValidationFailure('ID approbation requis')));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(
|
|
const RejectTransactionEvent(
|
|
approvalId: 'appr-1',
|
|
reason: 'bad data',
|
|
),
|
|
),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('reject'),
|
|
const ApprovalError('ID approbation requis'),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'TransactionRejected has correct default message',
|
|
build: () {
|
|
when(mockRejectTransaction(
|
|
approvalId: anyNamed('approvalId'),
|
|
reason: anyNamed('reason'),
|
|
)).thenAnswer((_) async => Right(_rejectedApproval));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(
|
|
const RejectTransactionEvent(
|
|
approvalId: 'appr-1',
|
|
reason: 'Raison valide',
|
|
),
|
|
),
|
|
expect: () => [
|
|
const ApprovalActionInProgress('reject'),
|
|
predicate<ApprovalState>(
|
|
(s) =>
|
|
s is TransactionRejected &&
|
|
s.message == 'Transaction rejetée avec succès',
|
|
'TransactionRejected with correct message',
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
// ─── RefreshApprovals ────────────────────────────────────────────────────
|
|
|
|
group('RefreshApprovals', () {
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits ApprovalsLoaded without ApprovalsLoading during refresh',
|
|
build: () {
|
|
when(mockGetPendingApprovals(
|
|
organizationId: anyNamed('organizationId'),
|
|
)).thenAnswer((_) async => Right([_pendingApproval]));
|
|
return buildBloc();
|
|
},
|
|
// No seed — run from ApprovalInitial. RefreshApprovals skips ApprovalsLoading.
|
|
act: (b) => b.add(const RefreshApprovals(organizationId: 'org-1')),
|
|
// No ApprovalsLoading emitted — goes straight to result
|
|
expect: () => [
|
|
ApprovalsLoaded(
|
|
approvals: [_pendingApproval],
|
|
pendingCount: 1,
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits ApprovalsEmpty when refresh returns empty list',
|
|
build: () {
|
|
when(mockGetPendingApprovals(
|
|
organizationId: anyNamed('organizationId'),
|
|
)).thenAnswer((_) async => const Right([]));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const RefreshApprovals()),
|
|
expect: () => [const ApprovalsEmpty()],
|
|
);
|
|
|
|
blocTest<ApprovalBloc, ApprovalState>(
|
|
'emits ApprovalError on refresh failure',
|
|
build: () {
|
|
when(mockGetPendingApprovals(
|
|
organizationId: anyNamed('organizationId'),
|
|
)).thenAnswer((_) async =>
|
|
const Left(ServerFailure('serveur indisponible')));
|
|
return buildBloc();
|
|
},
|
|
act: (b) => b.add(const RefreshApprovals()),
|
|
expect: () => [const ApprovalError('serveur indisponible')],
|
|
);
|
|
});
|
|
|
|
// ─── State equality ──────────────────────────────────────────────────────
|
|
|
|
group('State equality / props', () {
|
|
test('ApprovalsLoaded equality', () {
|
|
final s1 = ApprovalsLoaded(
|
|
approvals: [_pendingApproval],
|
|
pendingCount: 1,
|
|
);
|
|
final s2 = ApprovalsLoaded(
|
|
approvals: [_pendingApproval],
|
|
pendingCount: 1,
|
|
);
|
|
expect(s1, equals(s2));
|
|
});
|
|
|
|
test('ApprovalActionInProgress equality', () {
|
|
expect(
|
|
const ApprovalActionInProgress('approve'),
|
|
equals(const ApprovalActionInProgress('approve')),
|
|
);
|
|
expect(
|
|
const ApprovalActionInProgress('approve'),
|
|
isNot(equals(const ApprovalActionInProgress('reject'))),
|
|
);
|
|
});
|
|
|
|
test('TransactionApproval.isPending is true for pending status', () {
|
|
expect(_pendingApproval.isPending, isTrue);
|
|
});
|
|
|
|
test('TransactionApproval.isCompleted is false for pending', () {
|
|
expect(_pendingApproval.isCompleted, isFalse);
|
|
});
|
|
|
|
test('TransactionApproval.requiredApprovals matches level', () {
|
|
expect(_pendingApproval.requiredApprovals, equals(1)); // level1
|
|
});
|
|
|
|
test('ApprovalsEmpty has default message', () {
|
|
const s = ApprovalsEmpty();
|
|
expect(s.message, 'Aucune approbation en attente');
|
|
});
|
|
});
|
|
}
|