···
-
import 'package:coves_flutter/models/post.dart';
-
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
-
import 'package:coves_flutter/providers/vote_provider.dart';
-
import 'package:coves_flutter/services/coves_api_service.dart';
-
import 'package:flutter_test/flutter_test.dart';
-
import 'package:mockito/annotations.dart';
-
import 'package:mockito/mockito.dart';
-
import 'feed_provider_test.mocks.dart';
-
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
-
group('FeedProvider', () {
-
late FeedProvider feedProvider;
-
late MockAuthProvider mockAuthProvider;
-
late MockCovesApiService mockApiService;
-
mockAuthProvider = MockAuthProvider();
-
mockApiService = MockCovesApiService();
-
// Mock default auth state
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
// Mock the token getter
-
mockAuthProvider.getAccessToken(),
-
).thenAnswer((_) async => 'test-token');
-
// Create feed provider with injected mock service
-
feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService);
-
feedProvider.dispose();
-
test('should load discover feed when authenticated by default', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProvider.loadFeed(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
expect(feedProvider.isLoading, false);
-
test('should load timeline when feed type is For You', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProvider.setFeedType(FeedType.forYou);
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
expect(feedProvider.isLoading, false);
-
test('should load discover feed when not authenticated', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProvider.loadFeed(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
group('fetchTimeline', () {
-
test('should fetch timeline successfully', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost(), _createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 2);
-
expect(feedProvider.hasMore, true);
-
expect(feedProvider.error, null);
-
test('should handle network errors', () async {
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenThrow(Exception('Network error'));
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.error, isNotNull);
-
expect(feedProvider.isLoading, false);
-
test('should append posts when not refreshing', () async {
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => firstResponse);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
// Second load (pagination)
-
final secondResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
).thenAnswer((_) async => secondResponse);
-
await feedProvider.fetchTimeline();
-
expect(feedProvider.posts.length, 2);
-
test('should replace posts when refreshing', () async {
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => firstResponse);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
final refreshResponse = TimelineResponse(
-
feed: [_createMockPost(), _createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
).thenAnswer((_) async => refreshResponse);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 2);
-
test('should set hasMore to false when no cursor', () async {
-
final response = TimelineResponse(feed: [_createMockPost()]);
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => response);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.hasMore, false);
-
group('fetchDiscover', () {
-
test('should fetch discover feed successfully', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProvider.fetchDiscover(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
test('should handle empty feed', () async {
-
final emptyResponse = TimelineResponse(feed: []);
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => emptyResponse);
-
await feedProvider.fetchDiscover(refresh: true);
-
expect(feedProvider.posts.isEmpty, true);
-
expect(feedProvider.hasMore, false);
-
test('should load more posts', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => firstResponse);
-
await feedProvider.setFeedType(FeedType.forYou);
-
final secondResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
).thenAnswer((_) async => secondResponse);
-
await feedProvider.loadMore();
-
expect(feedProvider.posts.length, 2);
-
test('should not load more if already loading', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final response = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => response);
-
await feedProvider.setFeedType(FeedType.forYou);
-
await feedProvider.loadMore();
-
// Should not make additional calls while loading
-
test('should not load more if hasMore is false', () async {
-
final response = TimelineResponse(feed: [_createMockPost()]);
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => response);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.hasMore, false);
-
await feedProvider.loadMore();
-
// Should not attempt to load more
-
test('should retry after error', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenThrow(Exception('Network error'));
-
await feedProvider.setFeedType(FeedType.forYou);
-
expect(feedProvider.error, isNotNull);
-
final successResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => successResponse);
-
await feedProvider.retry();
-
expect(feedProvider.error, null);
-
expect(feedProvider.posts.length, 1);
-
group('State Management', () {
-
test('should notify listeners on state change', () async {
-
var notificationCount = 0;
-
feedProvider.addListener(() {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(notificationCount, greaterThan(0));
-
test('should manage loading states correctly', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
final loadFuture = feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.isLoading, true);
-
// Should not be loading anymore
-
expect(feedProvider.isLoading, false);
-
group('Vote state initialization from viewer data', () {
-
late MockVoteProvider mockVoteProvider;
-
late FeedProvider feedProviderWithVotes;
-
mockVoteProvider = MockVoteProvider();
-
feedProviderWithVotes = FeedProvider(
-
apiService: mockApiService,
-
voteProvider: mockVoteProvider,
-
feedProviderWithVotes.dispose();
-
test('should initialize vote state when viewer.vote is "up"', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final mockResponse = TimelineResponse(
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
test('should initialize vote state when viewer.vote is "down"', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final mockResponse = TimelineResponse(
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
'should clear stale vote state when viewer.vote is null on refresh',
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
// Feed item with null vote (user removed vote on another device)
-
final mockResponse = TimelineResponse(
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
// Should call setInitialVoteState with null to clear stale state
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
'should initialize vote state for all feed items including no viewer',
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
final mockResponse = TimelineResponse(
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
_createMockPost(), // No viewer state
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
// Should be called for both posts
-
mockVoteProvider.setInitialVoteState(
-
postUri: anyNamed('postUri'),
-
voteDirection: anyNamed('voteDirection'),
-
voteUri: anyNamed('voteUri'),
-
test('should not initialize vote state when not authenticated', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
final mockResponse = TimelineResponse(
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
).thenAnswer((_) async => mockResponse);
-
await feedProviderWithVotes.fetchDiscover(refresh: true);
-
// Should NOT call setInitialVoteState when not authenticated
-
mockVoteProvider.setInitialVoteState(
-
postUri: anyNamed('postUri'),
-
voteDirection: anyNamed('voteDirection'),
-
voteUri: anyNamed('voteUri'),
-
// Helper function to create mock posts
-
FeedViewPost _createMockPost() {
-
uri: 'at://did:plc:test/app.bsky.feed.post/test',
-
displayName: 'Test User',
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
-
// Helper function to create mock posts with viewer state
-
FeedViewPost _createMockPostWithViewer({
-
displayName: 'Test User',
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
-
viewer: ViewerState(vote: vote, voteUri: voteUri),