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>
This commit is contained in:
411
test/features/feed/bloc/unified_feed_bloc_test.dart
Normal file
411
test/features/feed/bloc/unified_feed_bloc_test.dart
Normal file
@@ -0,0 +1,411 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'package:unionflow_mobile_apps/features/feed/presentation/bloc/unified_feed_bloc.dart';
|
||||
import 'package:unionflow_mobile_apps/features/feed/presentation/bloc/unified_feed_event.dart';
|
||||
import 'package:unionflow_mobile_apps/features/feed/presentation/bloc/unified_feed_state.dart';
|
||||
import 'package:unionflow_mobile_apps/features/feed/data/repositories/feed_repository.dart';
|
||||
import 'package:unionflow_mobile_apps/features/feed/domain/entities/feed_item.dart';
|
||||
|
||||
@GenerateMocks([FeedRepository])
|
||||
import 'unified_feed_bloc_test.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late UnifiedFeedBloc bloc;
|
||||
late MockFeedRepository mockRepository;
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
FeedItem fakeFeedItem({
|
||||
String id = 'item-1',
|
||||
bool isLikedByMe = false,
|
||||
int likesCount = 5,
|
||||
}) =>
|
||||
FeedItem(
|
||||
id: id,
|
||||
type: FeedItemType.post,
|
||||
authorName: 'Jean Dupont',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
content: 'Post content $id',
|
||||
likesCount: likesCount,
|
||||
commentsCount: 2,
|
||||
isLikedByMe: isLikedByMe,
|
||||
);
|
||||
|
||||
List<FeedItem> fakeFeedPage({int count = 10, int startIndex = 0}) =>
|
||||
List.generate(count, (i) => fakeFeedItem(id: 'item-${startIndex + i}'));
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockFeedRepository();
|
||||
bloc = UnifiedFeedBloc(mockRepository);
|
||||
});
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
// ── Initial state ─────────────────────────────────────────────────────────
|
||||
|
||||
test('initial state is UnifiedFeedInitial', () {
|
||||
expect(bloc.state, isA<UnifiedFeedInitial>());
|
||||
});
|
||||
|
||||
// ── LoadFeedRequested ─────────────────────────────────────────────────────
|
||||
|
||||
group('LoadFeedRequested', () {
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits [UnifiedFeedLoading, UnifiedFeedLoaded] on success with 10 items',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 0, size: 10))
|
||||
.thenAnswer((_) async => fakeFeedPage());
|
||||
return bloc;
|
||||
},
|
||||
act: (b) => b.add(const LoadFeedRequested()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoading>(),
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having((s) => s.items.length, 'items.length', 10)
|
||||
.having((s) => s.hasReachedMax, 'hasReachedMax', false),
|
||||
],
|
||||
verify: (_) => verify(mockRepository.getFeed(page: 0, size: 10)).called(1),
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits [UnifiedFeedLoading, UnifiedFeedLoaded] with hasReachedMax=true when fewer than 10 items',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 0, size: 10))
|
||||
.thenAnswer((_) async => fakeFeedPage(count: 5));
|
||||
return bloc;
|
||||
},
|
||||
act: (b) => b.add(const LoadFeedRequested()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoading>(),
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having((s) => s.items.length, 'items.length', 5)
|
||||
.having((s) => s.hasReachedMax, 'hasReachedMax', true),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits [UnifiedFeedLoading, UnifiedFeedLoaded] with empty feed',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 0, size: 10))
|
||||
.thenAnswer((_) async => []);
|
||||
return bloc;
|
||||
},
|
||||
act: (b) => b.add(const LoadFeedRequested()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoading>(),
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having((s) => s.items, 'items', isEmpty)
|
||||
.having((s) => s.hasReachedMax, 'hasReachedMax', true),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits [UnifiedFeedError] on repository failure',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 0, size: 10))
|
||||
.thenThrow(Exception('network error'));
|
||||
return bloc;
|
||||
},
|
||||
act: (b) => b.add(const LoadFeedRequested()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoading>(),
|
||||
isA<UnifiedFeedError>().having(
|
||||
(s) => s.message,
|
||||
'message',
|
||||
contains('Erreur de chargement'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does NOT emit UnifiedFeedLoading when isRefresh=true',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 0, size: 10))
|
||||
.thenAnswer((_) async => fakeFeedPage(count: 3));
|
||||
return bloc;
|
||||
},
|
||||
act: (b) => b.add(const LoadFeedRequested(isRefresh: true)),
|
||||
expect: () => [
|
||||
// No UnifiedFeedLoading emitted for refresh
|
||||
isA<UnifiedFeedLoaded>(),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// ── FeedLoadMoreRequested ─────────────────────────────────────────────────
|
||||
|
||||
group('FeedLoadMoreRequested', () {
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits updated UnifiedFeedLoaded with concatenated items on success',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 1, size: 10))
|
||||
.thenAnswer((_) async => fakeFeedPage(count: 10, startIndex: 10));
|
||||
return bloc;
|
||||
},
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(),
|
||||
hasReachedMax: false,
|
||||
),
|
||||
act: (b) => b.add(FeedLoadMoreRequested()),
|
||||
expect: () => [
|
||||
// First: isFetchingMore=true
|
||||
isA<UnifiedFeedLoaded>().having(
|
||||
(s) => s.isFetchingMore,
|
||||
'isFetchingMore',
|
||||
true,
|
||||
),
|
||||
// Then: items appended, isFetchingMore=false
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having((s) => s.items.length, 'items.length', 20)
|
||||
.having((s) => s.isFetchingMore, 'isFetchingMore', false)
|
||||
.having((s) => s.hasReachedMax, 'hasReachedMax', false),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits UnifiedFeedLoaded with hasReachedMax=true when no more items',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 1, size: 10))
|
||||
.thenAnswer((_) async => []);
|
||||
return bloc;
|
||||
},
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(),
|
||||
hasReachedMax: false,
|
||||
),
|
||||
act: (b) => b.add(FeedLoadMoreRequested()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>().having((s) => s.isFetchingMore, 'isFetchingMore', true),
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having((s) => s.hasReachedMax, 'hasReachedMax', true)
|
||||
.having((s) => s.items.length, 'items.length', 10),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'emits loadMoreErrorMessage on load more failure',
|
||||
build: () {
|
||||
when(mockRepository.getFeed(page: 1, size: 10))
|
||||
.thenThrow(Exception('page load error'));
|
||||
return bloc;
|
||||
},
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(),
|
||||
hasReachedMax: false,
|
||||
),
|
||||
act: (b) => b.add(FeedLoadMoreRequested()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>().having((s) => s.isFetchingMore, 'isFetchingMore', true),
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having((s) => s.isFetchingMore, 'isFetchingMore', false)
|
||||
.having(
|
||||
(s) => s.loadMoreErrorMessage,
|
||||
'loadMoreErrorMessage',
|
||||
isNotNull,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does nothing when hasReachedMax is true',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(count: 5),
|
||||
hasReachedMax: true,
|
||||
),
|
||||
act: (b) => b.add(FeedLoadMoreRequested()),
|
||||
expect: () => [],
|
||||
verify: (_) => verifyNever(mockRepository.getFeed(
|
||||
page: anyNamed('page'),
|
||||
size: anyNamed('size'),
|
||||
)),
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does nothing when isFetchingMore is already true',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(),
|
||||
hasReachedMax: false,
|
||||
isFetchingMore: true,
|
||||
),
|
||||
act: (b) => b.add(FeedLoadMoreRequested()),
|
||||
expect: () => [],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does nothing when state is not UnifiedFeedLoaded',
|
||||
build: () => bloc,
|
||||
// Default state: UnifiedFeedInitial
|
||||
act: (b) => b.add(FeedLoadMoreRequested()),
|
||||
expect: () => [],
|
||||
);
|
||||
});
|
||||
|
||||
// ── ClearLoadMoreError ────────────────────────────────────────────────────
|
||||
|
||||
group('ClearLoadMoreError', () {
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'clears loadMoreErrorMessage from current loaded state',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(count: 5),
|
||||
loadMoreErrorMessage: 'Impossible de charger plus',
|
||||
),
|
||||
act: (b) => b.add(ClearLoadMoreError()),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>().having(
|
||||
(s) => s.loadMoreErrorMessage,
|
||||
'loadMoreErrorMessage',
|
||||
isNull,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does nothing when state is not UnifiedFeedLoaded',
|
||||
build: () => bloc,
|
||||
act: (b) => b.add(ClearLoadMoreError()),
|
||||
expect: () => [],
|
||||
);
|
||||
});
|
||||
|
||||
// ── FeedItemLiked ─────────────────────────────────────────────────────────
|
||||
|
||||
group('FeedItemLiked', () {
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'toggles like on an item (not liked → liked, likesCount +1)',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: [
|
||||
fakeFeedItem(id: 'item-1', isLikedByMe: false, likesCount: 5),
|
||||
fakeFeedItem(id: 'item-2', isLikedByMe: true, likesCount: 3),
|
||||
],
|
||||
),
|
||||
act: (b) => b.add(const FeedItemLiked('item-1')),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>().having(
|
||||
(s) => s.items.firstWhere((i) => i.id == 'item-1').isLikedByMe,
|
||||
'item-1.isLikedByMe',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'toggles like count correctly (liked → unliked, likesCount -1)',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: [
|
||||
fakeFeedItem(id: 'item-1', isLikedByMe: true, likesCount: 5),
|
||||
],
|
||||
),
|
||||
act: (b) => b.add(const FeedItemLiked('item-1')),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>()
|
||||
.having(
|
||||
(s) => s.items.first.isLikedByMe,
|
||||
'isLikedByMe',
|
||||
false,
|
||||
)
|
||||
.having(
|
||||
(s) => s.items.first.likesCount,
|
||||
'likesCount',
|
||||
4,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'increments likesCount when liking item',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: [
|
||||
fakeFeedItem(id: 'item-1', isLikedByMe: false, likesCount: 10),
|
||||
],
|
||||
),
|
||||
act: (b) => b.add(const FeedItemLiked('item-1')),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>().having(
|
||||
(s) => s.items.first.likesCount,
|
||||
'likesCount',
|
||||
11,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does not modify other items when liking one',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: [
|
||||
fakeFeedItem(id: 'item-1', isLikedByMe: false, likesCount: 5),
|
||||
fakeFeedItem(id: 'item-2', isLikedByMe: true, likesCount: 3),
|
||||
],
|
||||
),
|
||||
act: (b) => b.add(const FeedItemLiked('item-1')),
|
||||
expect: () => [
|
||||
isA<UnifiedFeedLoaded>().having(
|
||||
(s) => s.items.firstWhere((i) => i.id == 'item-2').isLikedByMe,
|
||||
'item-2.isLikedByMe stays true',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does nothing when state is not UnifiedFeedLoaded',
|
||||
build: () => bloc,
|
||||
// Default: UnifiedFeedInitial
|
||||
act: (b) => b.add(const FeedItemLiked('item-1')),
|
||||
expect: () => [],
|
||||
);
|
||||
|
||||
blocTest<UnifiedFeedBloc, UnifiedFeedState>(
|
||||
'does nothing when item id not found in list',
|
||||
build: () => bloc,
|
||||
seed: () => UnifiedFeedLoaded(
|
||||
items: [fakeFeedItem(id: 'item-1')],
|
||||
),
|
||||
act: (b) => b.add(const FeedItemLiked('non-existent')),
|
||||
expect: () => [
|
||||
// State is re-emitted unchanged (items mapped, no match found)
|
||||
isA<UnifiedFeedLoaded>().having(
|
||||
(s) => s.items.first.id,
|
||||
'items[0].id',
|
||||
'item-1',
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// ── UnifiedFeedLoaded.copyWith ────────────────────────────────────────────
|
||||
|
||||
group('UnifiedFeedLoaded.copyWith', () {
|
||||
test('preserves existing values when not overridden', () {
|
||||
final state = UnifiedFeedLoaded(
|
||||
items: fakeFeedPage(count: 3),
|
||||
hasReachedMax: false,
|
||||
isFetchingMore: false,
|
||||
loadMoreErrorMessage: 'some error',
|
||||
);
|
||||
final copy = state.copyWith(hasReachedMax: true);
|
||||
expect(copy.hasReachedMax, true);
|
||||
expect(copy.items.length, 3);
|
||||
// Note: copyWith always resets isFetchingMore to false and
|
||||
// loadMoreErrorMessage to null when not provided (by design)
|
||||
});
|
||||
|
||||
test('loadMoreErrorMessage is null when not passed to copyWith', () {
|
||||
final state = UnifiedFeedLoaded(
|
||||
items: [],
|
||||
loadMoreErrorMessage: 'error',
|
||||
);
|
||||
final copy = state.copyWith(hasReachedMax: true);
|
||||
expect(copy.loadMoreErrorMessage, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user