## 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>
367 lines
13 KiB
Dart
367 lines
13 KiB
Dart
import 'package:bloc_test/bloc_test.dart';
|
|
import 'package:dio/dio.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/features/notifications/data/repositories/notification_feed_repository.dart';
|
|
import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notification_bloc.dart';
|
|
import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notification_event.dart';
|
|
import 'package:unionflow_mobile_apps/features/notifications/presentation/bloc/notification_state.dart';
|
|
|
|
@GenerateMocks([NotificationFeedRepository])
|
|
import 'notification_bloc_test.mocks.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
NotificationItem _makeItem({
|
|
String id = 'item-1',
|
|
String title = 'Test Notification',
|
|
String body = 'Corps du message',
|
|
bool isRead = false,
|
|
String category = 'system',
|
|
}) =>
|
|
NotificationItem(
|
|
id: id,
|
|
title: title,
|
|
body: body,
|
|
date: DateTime(2026, 4, 20),
|
|
isRead: isRead,
|
|
category: category,
|
|
);
|
|
|
|
DioException _cancelException() => DioException(
|
|
requestOptions: RequestOptions(path: '/notifications'),
|
|
type: DioExceptionType.cancel,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void main() {
|
|
late MockNotificationFeedRepository mockRepository;
|
|
|
|
setUpAll(() {
|
|
try {
|
|
AppConfig.initialize();
|
|
} catch (_) {
|
|
// Already initialized.
|
|
}
|
|
});
|
|
|
|
NotificationBloc buildBloc() => NotificationBloc(mockRepository);
|
|
|
|
setUp(() {
|
|
mockRepository = MockNotificationFeedRepository();
|
|
});
|
|
|
|
// ─── Initial state ────────────────────────────────────────────────────────
|
|
|
|
group('initial state', () {
|
|
test('is NotificationInitial', () {
|
|
expect(buildBloc().state, isA<NotificationInitial>());
|
|
});
|
|
});
|
|
|
|
// ─── LoadNotificationsRequested ───────────────────────────────────────────
|
|
|
|
group('LoadNotificationsRequested', () {
|
|
final items = [
|
|
_makeItem(),
|
|
_makeItem(id: 'item-2', title: 'Finance Alert', category: 'finance'),
|
|
_makeItem(id: 'item-3', title: 'Event', category: 'event', isRead: true),
|
|
];
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'emits [Loading, Loaded] with items on success',
|
|
build: buildBloc,
|
|
setUp: () =>
|
|
when(mockRepository.getNotifications()).thenAnswer((_) async => items),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
expect: () => [
|
|
isA<NotificationLoading>(),
|
|
isA<NotificationLoaded>()
|
|
.having((s) => s.items.length, 'count', 3)
|
|
.having((s) => s.items.first.id, 'first id', 'item-1'),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'emits [Loading, Loaded(empty)] when repository returns empty list',
|
|
build: buildBloc,
|
|
setUp: () =>
|
|
when(mockRepository.getNotifications()).thenAnswer((_) async => []),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
expect: () => [
|
|
isA<NotificationLoading>(),
|
|
isA<NotificationLoaded>().having((s) => s.items, 'items', isEmpty),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'emits [Loading, Error] on generic exception',
|
|
build: buildBloc,
|
|
setUp: () =>
|
|
when(mockRepository.getNotifications()).thenThrow(Exception('network')),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
expect: () => [
|
|
isA<NotificationLoading>(),
|
|
isA<NotificationError>()
|
|
.having((e) => e.message, 'message', contains('chargement')),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'emits [Loading, Error] on DioException (non-cancel)',
|
|
build: buildBloc,
|
|
setUp: () => when(mockRepository.getNotifications()).thenThrow(
|
|
DioException(
|
|
requestOptions: RequestOptions(path: '/notifications'),
|
|
type: DioExceptionType.connectionError,
|
|
),
|
|
),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
expect: () => [
|
|
isA<NotificationLoading>(),
|
|
isA<NotificationError>(),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'swallows DioException.cancel — no error emitted',
|
|
build: buildBloc,
|
|
setUp: () =>
|
|
when(mockRepository.getNotifications()).thenThrow(_cancelException()),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
expect: () => [isA<NotificationLoading>()],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'calls getNotifications exactly once',
|
|
build: buildBloc,
|
|
setUp: () =>
|
|
when(mockRepository.getNotifications()).thenAnswer((_) async => items),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
verify: (_) => verify(mockRepository.getNotifications()).called(1),
|
|
);
|
|
});
|
|
|
|
// ─── NotificationMarkedAsRead ─────────────────────────────────────────────
|
|
|
|
group('NotificationMarkedAsRead', () {
|
|
final items = [
|
|
_makeItem(id: 'item-1', isRead: false),
|
|
_makeItem(id: 'item-2', isRead: false),
|
|
_makeItem(id: 'item-3', isRead: true),
|
|
];
|
|
final loadedState = NotificationLoaded(items: items);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'emits Loaded with target item marked as read',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead('item-1')).thenAnswer((_) async {}),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-1')),
|
|
expect: () => [
|
|
isA<NotificationLoaded>()
|
|
.having(
|
|
(s) => s.items.firstWhere((i) => i.id == 'item-1').isRead,
|
|
'item-1 isRead',
|
|
true,
|
|
)
|
|
.having(
|
|
(s) => s.items.firstWhere((i) => i.id == 'item-2').isRead,
|
|
'item-2 unchanged',
|
|
false,
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'preserves all other item properties after marking read',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead('item-1')).thenAnswer((_) async {}),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-1')),
|
|
expect: () => [
|
|
isA<NotificationLoaded>().having(
|
|
(s) => s.items.firstWhere((i) => i.id == 'item-1').title,
|
|
'title preserved',
|
|
'Test Notification',
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'no re-emission when id not found (items unchanged, equatable dedup)',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead('unknown')).thenAnswer((_) async {}),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('unknown')),
|
|
// The map returns the same object references for non-matching items,
|
|
// resulting in an identical NotificationLoaded state — no re-emission.
|
|
expect: () => [],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'does nothing when state is not NotificationLoaded',
|
|
build: buildBloc,
|
|
// state is NotificationInitial
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-1')),
|
|
expect: () => [],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'does nothing when state is NotificationLoading',
|
|
build: buildBloc,
|
|
seed: () => NotificationLoading(),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-1')),
|
|
expect: () => [],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'emits Error when markAsRead throws',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead(any)).thenThrow(Exception('server error')),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-1')),
|
|
expect: () => [
|
|
isA<NotificationError>()
|
|
.having((e) => e.message, 'message', contains('marquer')),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'swallows DioException.cancel in markAsRead',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead(any)).thenThrow(_cancelException()),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-1')),
|
|
expect: () => [],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'calls markAsRead with correct id',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead('item-2')).thenAnswer((_) async {}),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-2')),
|
|
verify: (_) => verify(mockRepository.markAsRead('item-2')).called(1),
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'marking already-read item produces same state (no re-emission)',
|
|
build: buildBloc,
|
|
seed: () => loadedState,
|
|
setUp: () =>
|
|
when(mockRepository.markAsRead('item-3')).thenAnswer((_) async {}),
|
|
act: (b) => b.add(const NotificationMarkedAsRead('item-3')),
|
|
// Re-marking an already-read item rebuilds NotificationLoaded with
|
|
// identical items (isRead=true stays true). Equatable dedup prevents
|
|
// re-emission because the resulting state equals the current state.
|
|
expect: () => [],
|
|
);
|
|
});
|
|
|
|
// ─── Combined flows ───────────────────────────────────────────────────────
|
|
|
|
group('combined flows', () {
|
|
final items = [
|
|
_makeItem(id: 'a', isRead: false),
|
|
_makeItem(id: 'b', isRead: false),
|
|
];
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'load then mark as read produces correct final state',
|
|
build: buildBloc,
|
|
setUp: () {
|
|
when(mockRepository.getNotifications()).thenAnswer((_) async => items);
|
|
when(mockRepository.markAsRead('a')).thenAnswer((_) async {});
|
|
},
|
|
act: (b) async {
|
|
b.add(LoadNotificationsRequested());
|
|
await Future.delayed(Duration.zero);
|
|
b.add(const NotificationMarkedAsRead('a'));
|
|
},
|
|
expect: () => [
|
|
isA<NotificationLoading>(),
|
|
isA<NotificationLoaded>().having((s) => s.items.length, 'count', 2),
|
|
isA<NotificationLoaded>().having(
|
|
(s) => s.items.firstWhere((i) => i.id == 'a').isRead,
|
|
'a is read',
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<NotificationBloc, NotificationState>(
|
|
'reload after mark as read reflects fresh data',
|
|
build: buildBloc,
|
|
setUp: () {
|
|
final updated = [
|
|
_makeItem(id: 'a', isRead: true),
|
|
_makeItem(id: 'b', isRead: false),
|
|
];
|
|
when(mockRepository.getNotifications())
|
|
.thenAnswer((_) async => updated);
|
|
},
|
|
seed: () => NotificationLoaded(items: items),
|
|
act: (b) => b.add(LoadNotificationsRequested()),
|
|
expect: () => [
|
|
isA<NotificationLoading>(),
|
|
isA<NotificationLoaded>().having(
|
|
(s) => s.items.firstWhere((i) => i.id == 'a').isRead,
|
|
'a is read after reload',
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
// ─── NotificationItem equality ────────────────────────────────────────────
|
|
|
|
group('NotificationItem', () {
|
|
test('two items with same data are equal', () {
|
|
final a = _makeItem();
|
|
final b = _makeItem();
|
|
expect(a, b);
|
|
});
|
|
|
|
test('items with different isRead are not equal', () {
|
|
final a = _makeItem(isRead: false);
|
|
final b = _makeItem(isRead: true);
|
|
expect(a, isNot(b));
|
|
});
|
|
|
|
test('items with different category are not equal', () {
|
|
final a = _makeItem(category: 'system');
|
|
final b = _makeItem(category: 'finance');
|
|
expect(a, isNot(b));
|
|
});
|
|
});
|
|
|
|
// ─── State equality ───────────────────────────────────────────────────────
|
|
|
|
group('State equality', () {
|
|
final items = [_makeItem()];
|
|
|
|
test('NotificationLoaded with same items are equal', () {
|
|
expect(NotificationLoaded(items: items), NotificationLoaded(items: items));
|
|
});
|
|
|
|
test('NotificationError with same message are equal', () {
|
|
expect(
|
|
const NotificationError('fail'),
|
|
const NotificationError('fail'),
|
|
);
|
|
});
|
|
});
|
|
}
|