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 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()); }); // ── LoadFeedRequested ───────────────────────────────────────────────────── group('LoadFeedRequested', () { blocTest( '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(), isA() .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( '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(), isA() .having((s) => s.items.length, 'items.length', 5) .having((s) => s.hasReachedMax, 'hasReachedMax', true), ], ); blocTest( '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(), isA() .having((s) => s.items, 'items', isEmpty) .having((s) => s.hasReachedMax, 'hasReachedMax', true), ], ); blocTest( '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(), isA().having( (s) => s.message, 'message', contains('Erreur de chargement'), ), ], ); blocTest( '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(), ], ); }); // ── FeedLoadMoreRequested ───────────────────────────────────────────────── group('FeedLoadMoreRequested', () { blocTest( '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().having( (s) => s.isFetchingMore, 'isFetchingMore', true, ), // Then: items appended, isFetchingMore=false isA() .having((s) => s.items.length, 'items.length', 20) .having((s) => s.isFetchingMore, 'isFetchingMore', false) .having((s) => s.hasReachedMax, 'hasReachedMax', false), ], ); blocTest( '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().having((s) => s.isFetchingMore, 'isFetchingMore', true), isA() .having((s) => s.hasReachedMax, 'hasReachedMax', true) .having((s) => s.items.length, 'items.length', 10), ], ); blocTest( '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().having((s) => s.isFetchingMore, 'isFetchingMore', true), isA() .having((s) => s.isFetchingMore, 'isFetchingMore', false) .having( (s) => s.loadMoreErrorMessage, 'loadMoreErrorMessage', isNotNull, ), ], ); blocTest( '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( 'does nothing when isFetchingMore is already true', build: () => bloc, seed: () => UnifiedFeedLoaded( items: fakeFeedPage(), hasReachedMax: false, isFetchingMore: true, ), act: (b) => b.add(FeedLoadMoreRequested()), expect: () => [], ); blocTest( 'does nothing when state is not UnifiedFeedLoaded', build: () => bloc, // Default state: UnifiedFeedInitial act: (b) => b.add(FeedLoadMoreRequested()), expect: () => [], ); }); // ── ClearLoadMoreError ──────────────────────────────────────────────────── group('ClearLoadMoreError', () { blocTest( '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().having( (s) => s.loadMoreErrorMessage, 'loadMoreErrorMessage', isNull, ), ], ); blocTest( 'does nothing when state is not UnifiedFeedLoaded', build: () => bloc, act: (b) => b.add(ClearLoadMoreError()), expect: () => [], ); }); // ── FeedItemLiked ───────────────────────────────────────────────────────── group('FeedItemLiked', () { blocTest( '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().having( (s) => s.items.firstWhere((i) => i.id == 'item-1').isLikedByMe, 'item-1.isLikedByMe', true, ), ], ); blocTest( '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() .having( (s) => s.items.first.isLikedByMe, 'isLikedByMe', false, ) .having( (s) => s.items.first.likesCount, 'likesCount', 4, ), ], ); blocTest( '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().having( (s) => s.items.first.likesCount, 'likesCount', 11, ), ], ); blocTest( '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().having( (s) => s.items.firstWhere((i) => i.id == 'item-2').isLikedByMe, 'item-2.isLikedByMe stays true', true, ), ], ); blocTest( 'does nothing when state is not UnifiedFeedLoaded', build: () => bloc, // Default: UnifiedFeedInitial act: (b) => b.add(const FeedItemLiked('item-1')), expect: () => [], ); blocTest( '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().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); }); }); }