···
1
-
import 'package:coves_flutter/models/post.dart';
2
-
import 'package:coves_flutter/providers/auth_provider.dart';
3
-
import 'package:coves_flutter/providers/feed_provider.dart';
4
-
import 'package:coves_flutter/providers/vote_provider.dart';
5
-
import 'package:coves_flutter/services/coves_api_service.dart';
6
-
import 'package:flutter_test/flutter_test.dart';
7
-
import 'package:mockito/annotations.dart';
8
-
import 'package:mockito/mockito.dart';
10
-
import 'feed_provider_test.mocks.dart';
13
-
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
15
-
group('FeedProvider', () {
16
-
late FeedProvider feedProvider;
17
-
late MockAuthProvider mockAuthProvider;
18
-
late MockCovesApiService mockApiService;
21
-
mockAuthProvider = MockAuthProvider();
22
-
mockApiService = MockCovesApiService();
24
-
// Mock default auth state
25
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
27
-
// Mock the token getter
29
-
mockAuthProvider.getAccessToken(),
30
-
).thenAnswer((_) async => 'test-token');
32
-
// Create feed provider with injected mock service
33
-
feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService);
37
-
feedProvider.dispose();
40
-
group('loadFeed', () {
41
-
test('should load discover feed when authenticated by default', () async {
42
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
44
-
final mockResponse = TimelineResponse(
45
-
feed: [_createMockPost()],
46
-
cursor: 'next-cursor',
50
-
mockApiService.getDiscover(
51
-
sort: anyNamed('sort'),
52
-
timeframe: anyNamed('timeframe'),
53
-
limit: anyNamed('limit'),
54
-
cursor: anyNamed('cursor'),
56
-
).thenAnswer((_) async => mockResponse);
58
-
await feedProvider.loadFeed(refresh: true);
60
-
expect(feedProvider.posts.length, 1);
61
-
expect(feedProvider.error, null);
62
-
expect(feedProvider.isLoading, false);
65
-
test('should load timeline when feed type is For You', () async {
66
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
68
-
final mockResponse = TimelineResponse(
69
-
feed: [_createMockPost()],
70
-
cursor: 'next-cursor',
74
-
mockApiService.getTimeline(
75
-
sort: anyNamed('sort'),
76
-
timeframe: anyNamed('timeframe'),
77
-
limit: anyNamed('limit'),
78
-
cursor: anyNamed('cursor'),
80
-
).thenAnswer((_) async => mockResponse);
82
-
await feedProvider.setFeedType(FeedType.forYou);
84
-
expect(feedProvider.posts.length, 1);
85
-
expect(feedProvider.error, null);
86
-
expect(feedProvider.isLoading, false);
89
-
test('should load discover feed when not authenticated', () async {
90
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
92
-
final mockResponse = TimelineResponse(
93
-
feed: [_createMockPost()],
94
-
cursor: 'next-cursor',
98
-
mockApiService.getDiscover(
99
-
sort: anyNamed('sort'),
100
-
timeframe: anyNamed('timeframe'),
101
-
limit: anyNamed('limit'),
102
-
cursor: anyNamed('cursor'),
104
-
).thenAnswer((_) async => mockResponse);
106
-
await feedProvider.loadFeed(refresh: true);
108
-
expect(feedProvider.posts.length, 1);
109
-
expect(feedProvider.error, null);
113
-
group('fetchTimeline', () {
114
-
test('should fetch timeline successfully', () async {
115
-
final mockResponse = TimelineResponse(
116
-
feed: [_createMockPost(), _createMockPost()],
117
-
cursor: 'next-cursor',
121
-
mockApiService.getTimeline(
122
-
sort: anyNamed('sort'),
123
-
timeframe: anyNamed('timeframe'),
124
-
limit: anyNamed('limit'),
125
-
cursor: anyNamed('cursor'),
127
-
).thenAnswer((_) async => mockResponse);
129
-
await feedProvider.fetchTimeline(refresh: true);
131
-
expect(feedProvider.posts.length, 2);
132
-
expect(feedProvider.hasMore, true);
133
-
expect(feedProvider.error, null);
136
-
test('should handle network errors', () async {
138
-
mockApiService.getTimeline(
139
-
sort: anyNamed('sort'),
140
-
timeframe: anyNamed('timeframe'),
141
-
limit: anyNamed('limit'),
142
-
cursor: anyNamed('cursor'),
144
-
).thenThrow(Exception('Network error'));
146
-
await feedProvider.fetchTimeline(refresh: true);
148
-
expect(feedProvider.error, isNotNull);
149
-
expect(feedProvider.isLoading, false);
152
-
test('should append posts when not refreshing', () async {
154
-
final firstResponse = TimelineResponse(
155
-
feed: [_createMockPost()],
156
-
cursor: 'cursor-1',
160
-
mockApiService.getTimeline(
161
-
sort: anyNamed('sort'),
162
-
timeframe: anyNamed('timeframe'),
163
-
limit: anyNamed('limit'),
164
-
cursor: anyNamed('cursor'),
166
-
).thenAnswer((_) async => firstResponse);
168
-
await feedProvider.fetchTimeline(refresh: true);
169
-
expect(feedProvider.posts.length, 1);
171
-
// Second load (pagination)
172
-
final secondResponse = TimelineResponse(
173
-
feed: [_createMockPost()],
174
-
cursor: 'cursor-2',
178
-
mockApiService.getTimeline(
179
-
sort: anyNamed('sort'),
180
-
timeframe: anyNamed('timeframe'),
181
-
limit: anyNamed('limit'),
182
-
cursor: 'cursor-1',
184
-
).thenAnswer((_) async => secondResponse);
186
-
await feedProvider.fetchTimeline();
187
-
expect(feedProvider.posts.length, 2);
190
-
test('should replace posts when refreshing', () async {
192
-
final firstResponse = TimelineResponse(
193
-
feed: [_createMockPost()],
194
-
cursor: 'cursor-1',
198
-
mockApiService.getTimeline(
199
-
sort: anyNamed('sort'),
200
-
timeframe: anyNamed('timeframe'),
201
-
limit: anyNamed('limit'),
202
-
cursor: anyNamed('cursor'),
204
-
).thenAnswer((_) async => firstResponse);
206
-
await feedProvider.fetchTimeline(refresh: true);
207
-
expect(feedProvider.posts.length, 1);
210
-
final refreshResponse = TimelineResponse(
211
-
feed: [_createMockPost(), _createMockPost()],
212
-
cursor: 'cursor-2',
216
-
mockApiService.getTimeline(
217
-
sort: anyNamed('sort'),
218
-
timeframe: anyNamed('timeframe'),
219
-
limit: anyNamed('limit'),
221
-
).thenAnswer((_) async => refreshResponse);
223
-
await feedProvider.fetchTimeline(refresh: true);
224
-
expect(feedProvider.posts.length, 2);
227
-
test('should set hasMore to false when no cursor', () async {
228
-
final response = TimelineResponse(feed: [_createMockPost()]);
231
-
mockApiService.getTimeline(
232
-
sort: anyNamed('sort'),
233
-
timeframe: anyNamed('timeframe'),
234
-
limit: anyNamed('limit'),
235
-
cursor: anyNamed('cursor'),
237
-
).thenAnswer((_) async => response);
239
-
await feedProvider.fetchTimeline(refresh: true);
241
-
expect(feedProvider.hasMore, false);
245
-
group('fetchDiscover', () {
246
-
test('should fetch discover feed successfully', () async {
247
-
final mockResponse = TimelineResponse(
248
-
feed: [_createMockPost()],
249
-
cursor: 'next-cursor',
253
-
mockApiService.getDiscover(
254
-
sort: anyNamed('sort'),
255
-
timeframe: anyNamed('timeframe'),
256
-
limit: anyNamed('limit'),
257
-
cursor: anyNamed('cursor'),
259
-
).thenAnswer((_) async => mockResponse);
261
-
await feedProvider.fetchDiscover(refresh: true);
263
-
expect(feedProvider.posts.length, 1);
264
-
expect(feedProvider.error, null);
267
-
test('should handle empty feed', () async {
268
-
final emptyResponse = TimelineResponse(feed: []);
271
-
mockApiService.getDiscover(
272
-
sort: anyNamed('sort'),
273
-
timeframe: anyNamed('timeframe'),
274
-
limit: anyNamed('limit'),
275
-
cursor: anyNamed('cursor'),
277
-
).thenAnswer((_) async => emptyResponse);
279
-
await feedProvider.fetchDiscover(refresh: true);
281
-
expect(feedProvider.posts.isEmpty, true);
282
-
expect(feedProvider.hasMore, false);
286
-
group('loadMore', () {
287
-
test('should load more posts', () async {
288
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
291
-
final firstResponse = TimelineResponse(
292
-
feed: [_createMockPost()],
293
-
cursor: 'cursor-1',
297
-
mockApiService.getTimeline(
298
-
sort: anyNamed('sort'),
299
-
timeframe: anyNamed('timeframe'),
300
-
limit: anyNamed('limit'),
301
-
cursor: anyNamed('cursor'),
303
-
).thenAnswer((_) async => firstResponse);
305
-
await feedProvider.setFeedType(FeedType.forYou);
308
-
final secondResponse = TimelineResponse(
309
-
feed: [_createMockPost()],
310
-
cursor: 'cursor-2',
314
-
mockApiService.getTimeline(
315
-
sort: anyNamed('sort'),
316
-
timeframe: anyNamed('timeframe'),
317
-
limit: anyNamed('limit'),
318
-
cursor: 'cursor-1',
320
-
).thenAnswer((_) async => secondResponse);
322
-
await feedProvider.loadMore();
324
-
expect(feedProvider.posts.length, 2);
327
-
test('should not load more if already loading', () async {
328
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
330
-
final response = TimelineResponse(
331
-
feed: [_createMockPost()],
332
-
cursor: 'cursor-1',
336
-
mockApiService.getTimeline(
337
-
sort: anyNamed('sort'),
338
-
timeframe: anyNamed('timeframe'),
339
-
limit: anyNamed('limit'),
340
-
cursor: anyNamed('cursor'),
342
-
).thenAnswer((_) async => response);
344
-
await feedProvider.setFeedType(FeedType.forYou);
345
-
await feedProvider.loadMore();
347
-
// Should not make additional calls while loading
350
-
test('should not load more if hasMore is false', () async {
351
-
final response = TimelineResponse(feed: [_createMockPost()]);
354
-
mockApiService.getTimeline(
355
-
sort: anyNamed('sort'),
356
-
timeframe: anyNamed('timeframe'),
357
-
limit: anyNamed('limit'),
358
-
cursor: anyNamed('cursor'),
360
-
).thenAnswer((_) async => response);
362
-
await feedProvider.fetchTimeline(refresh: true);
363
-
expect(feedProvider.hasMore, false);
365
-
await feedProvider.loadMore();
366
-
// Should not attempt to load more
370
-
group('retry', () {
371
-
test('should retry after error', () async {
372
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
376
-
mockApiService.getTimeline(
377
-
sort: anyNamed('sort'),
378
-
timeframe: anyNamed('timeframe'),
379
-
limit: anyNamed('limit'),
380
-
cursor: anyNamed('cursor'),
382
-
).thenThrow(Exception('Network error'));
384
-
await feedProvider.setFeedType(FeedType.forYou);
385
-
expect(feedProvider.error, isNotNull);
388
-
final successResponse = TimelineResponse(
389
-
feed: [_createMockPost()],
394
-
mockApiService.getTimeline(
395
-
sort: anyNamed('sort'),
396
-
timeframe: anyNamed('timeframe'),
397
-
limit: anyNamed('limit'),
398
-
cursor: anyNamed('cursor'),
400
-
).thenAnswer((_) async => successResponse);
402
-
await feedProvider.retry();
404
-
expect(feedProvider.error, null);
405
-
expect(feedProvider.posts.length, 1);
409
-
group('State Management', () {
410
-
test('should notify listeners on state change', () async {
411
-
var notificationCount = 0;
412
-
feedProvider.addListener(() {
413
-
notificationCount++;
416
-
final mockResponse = TimelineResponse(
417
-
feed: [_createMockPost()],
422
-
mockApiService.getTimeline(
423
-
sort: anyNamed('sort'),
424
-
timeframe: anyNamed('timeframe'),
425
-
limit: anyNamed('limit'),
426
-
cursor: anyNamed('cursor'),
428
-
).thenAnswer((_) async => mockResponse);
430
-
await feedProvider.fetchTimeline(refresh: true);
432
-
expect(notificationCount, greaterThan(0));
435
-
test('should manage loading states correctly', () async {
436
-
final mockResponse = TimelineResponse(
437
-
feed: [_createMockPost()],
442
-
mockApiService.getTimeline(
443
-
sort: anyNamed('sort'),
444
-
timeframe: anyNamed('timeframe'),
445
-
limit: anyNamed('limit'),
446
-
cursor: anyNamed('cursor'),
448
-
).thenAnswer((_) async {
449
-
await Future.delayed(const Duration(milliseconds: 100));
450
-
return mockResponse;
453
-
final loadFuture = feedProvider.fetchTimeline(refresh: true);
455
-
// Should be loading
456
-
expect(feedProvider.isLoading, true);
460
-
// Should not be loading anymore
461
-
expect(feedProvider.isLoading, false);
465
-
group('Vote state initialization from viewer data', () {
466
-
late MockVoteProvider mockVoteProvider;
467
-
late FeedProvider feedProviderWithVotes;
470
-
mockVoteProvider = MockVoteProvider();
471
-
feedProviderWithVotes = FeedProvider(
473
-
apiService: mockApiService,
474
-
voteProvider: mockVoteProvider,
479
-
feedProviderWithVotes.dispose();
482
-
test('should initialize vote state when viewer.vote is "up"', () async {
483
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
485
-
final mockResponse = TimelineResponse(
487
-
_createMockPostWithViewer(
488
-
uri: 'at://did:plc:test/social.coves.post.record/1',
490
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
497
-
mockApiService.getTimeline(
498
-
sort: anyNamed('sort'),
499
-
timeframe: anyNamed('timeframe'),
500
-
limit: anyNamed('limit'),
501
-
cursor: anyNamed('cursor'),
503
-
).thenAnswer((_) async => mockResponse);
505
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
508
-
mockVoteProvider.setInitialVoteState(
509
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
510
-
voteDirection: 'up',
511
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
516
-
test('should initialize vote state when viewer.vote is "down"', () async {
517
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
519
-
final mockResponse = TimelineResponse(
521
-
_createMockPostWithViewer(
522
-
uri: 'at://did:plc:test/social.coves.post.record/1',
524
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
531
-
mockApiService.getTimeline(
532
-
sort: anyNamed('sort'),
533
-
timeframe: anyNamed('timeframe'),
534
-
limit: anyNamed('limit'),
535
-
cursor: anyNamed('cursor'),
537
-
).thenAnswer((_) async => mockResponse);
539
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
542
-
mockVoteProvider.setInitialVoteState(
543
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
544
-
voteDirection: 'down',
545
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
551
-
'should clear stale vote state when viewer.vote is null on refresh',
553
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
555
-
// Feed item with null vote (user removed vote on another device)
556
-
final mockResponse = TimelineResponse(
558
-
_createMockPostWithViewer(
559
-
uri: 'at://did:plc:test/social.coves.post.record/1',
568
-
mockApiService.getTimeline(
569
-
sort: anyNamed('sort'),
570
-
timeframe: anyNamed('timeframe'),
571
-
limit: anyNamed('limit'),
572
-
cursor: anyNamed('cursor'),
574
-
).thenAnswer((_) async => mockResponse);
576
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
578
-
// Should call setInitialVoteState with null to clear stale state
580
-
mockVoteProvider.setInitialVoteState(
581
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
582
-
voteDirection: null,
590
-
'should initialize vote state for all feed items including no viewer',
592
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
594
-
final mockResponse = TimelineResponse(
596
-
_createMockPostWithViewer(
597
-
uri: 'at://did:plc:test/social.coves.post.record/1',
599
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
601
-
_createMockPost(), // No viewer state
607
-
mockApiService.getTimeline(
608
-
sort: anyNamed('sort'),
609
-
timeframe: anyNamed('timeframe'),
610
-
limit: anyNamed('limit'),
611
-
cursor: anyNamed('cursor'),
613
-
).thenAnswer((_) async => mockResponse);
615
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
617
-
// Should be called for both posts
619
-
mockVoteProvider.setInitialVoteState(
620
-
postUri: anyNamed('postUri'),
621
-
voteDirection: anyNamed('voteDirection'),
622
-
voteUri: anyNamed('voteUri'),
628
-
test('should not initialize vote state when not authenticated', () async {
629
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
631
-
final mockResponse = TimelineResponse(
633
-
_createMockPostWithViewer(
634
-
uri: 'at://did:plc:test/social.coves.post.record/1',
636
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
643
-
mockApiService.getDiscover(
644
-
sort: anyNamed('sort'),
645
-
timeframe: anyNamed('timeframe'),
646
-
limit: anyNamed('limit'),
647
-
cursor: anyNamed('cursor'),
649
-
).thenAnswer((_) async => mockResponse);
651
-
await feedProviderWithVotes.fetchDiscover(refresh: true);
653
-
// Should NOT call setInitialVoteState when not authenticated
655
-
mockVoteProvider.setInitialVoteState(
656
-
postUri: anyNamed('postUri'),
657
-
voteDirection: anyNamed('voteDirection'),
658
-
voteUri: anyNamed('voteUri'),
666
-
// Helper function to create mock posts
667
-
FeedViewPost _createMockPost() {
668
-
return FeedViewPost(
670
-
uri: 'at://did:plc:test/app.bsky.feed.post/test',
673
-
author: AuthorView(
674
-
did: 'did:plc:author',
675
-
handle: 'test.user',
676
-
displayName: 'Test User',
678
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
679
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
680
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
682
-
title: 'Test Post',
683
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
689
-
// Helper function to create mock posts with viewer state
690
-
FeedViewPost _createMockPostWithViewer({
691
-
required String uri,
695
-
return FeedViewPost(
700
-
author: AuthorView(
701
-
did: 'did:plc:author',
702
-
handle: 'test.user',
703
-
displayName: 'Test User',
705
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
706
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
707
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
709
-
title: 'Test Post',
710
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
712
-
viewer: ViewerState(vote: vote, voteUri: voteUri),