Main coves client
1import 'package:coves_flutter/models/post.dart';
2import 'package:coves_flutter/providers/auth_provider.dart';
3import 'package:coves_flutter/providers/feed_provider.dart';
4import 'package:coves_flutter/providers/vote_provider.dart';
5import 'package:coves_flutter/services/coves_api_service.dart';
6import 'package:flutter_test/flutter_test.dart';
7import 'package:mockito/annotations.dart';
8import 'package:mockito/mockito.dart';
9
10import 'feed_provider_test.mocks.dart';
11
12// Generate mocks
13@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
14void main() {
15 group('FeedProvider', () {
16 late FeedProvider feedProvider;
17 late MockAuthProvider mockAuthProvider;
18 late MockCovesApiService mockApiService;
19
20 setUp(() {
21 mockAuthProvider = MockAuthProvider();
22 mockApiService = MockCovesApiService();
23
24 // Mock default auth state
25 when(mockAuthProvider.isAuthenticated).thenReturn(false);
26
27 // Mock the token getter
28 when(
29 mockAuthProvider.getAccessToken(),
30 ).thenAnswer((_) async => 'test-token');
31
32 // Create feed provider with injected mock service
33 feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService);
34 });
35
36 tearDown(() {
37 feedProvider.dispose();
38 });
39
40 group('loadFeed', () {
41 test('should load discover feed when authenticated by default', () async {
42 when(mockAuthProvider.isAuthenticated).thenReturn(true);
43
44 final mockResponse = TimelineResponse(
45 feed: [_createMockPost()],
46 cursor: 'next-cursor',
47 );
48
49 when(
50 mockApiService.getDiscover(
51 sort: anyNamed('sort'),
52 timeframe: anyNamed('timeframe'),
53 limit: anyNamed('limit'),
54 cursor: anyNamed('cursor'),
55 ),
56 ).thenAnswer((_) async => mockResponse);
57
58 await feedProvider.loadFeed(refresh: true);
59
60 expect(feedProvider.posts.length, 1);
61 expect(feedProvider.error, null);
62 expect(feedProvider.isLoading, false);
63 });
64
65 test('should load timeline when feed type is For You', () async {
66 when(mockAuthProvider.isAuthenticated).thenReturn(true);
67
68 final mockResponse = TimelineResponse(
69 feed: [_createMockPost()],
70 cursor: 'next-cursor',
71 );
72
73 when(
74 mockApiService.getTimeline(
75 sort: anyNamed('sort'),
76 timeframe: anyNamed('timeframe'),
77 limit: anyNamed('limit'),
78 cursor: anyNamed('cursor'),
79 ),
80 ).thenAnswer((_) async => mockResponse);
81
82 await feedProvider.setFeedType(FeedType.forYou);
83
84 expect(feedProvider.posts.length, 1);
85 expect(feedProvider.error, null);
86 expect(feedProvider.isLoading, false);
87 });
88
89 test('should load discover feed when not authenticated', () async {
90 when(mockAuthProvider.isAuthenticated).thenReturn(false);
91
92 final mockResponse = TimelineResponse(
93 feed: [_createMockPost()],
94 cursor: 'next-cursor',
95 );
96
97 when(
98 mockApiService.getDiscover(
99 sort: anyNamed('sort'),
100 timeframe: anyNamed('timeframe'),
101 limit: anyNamed('limit'),
102 cursor: anyNamed('cursor'),
103 ),
104 ).thenAnswer((_) async => mockResponse);
105
106 await feedProvider.loadFeed(refresh: true);
107
108 expect(feedProvider.posts.length, 1);
109 expect(feedProvider.error, null);
110 });
111 });
112
113 group('fetchTimeline', () {
114 test('should fetch timeline successfully', () async {
115 final mockResponse = TimelineResponse(
116 feed: [_createMockPost(), _createMockPost()],
117 cursor: 'next-cursor',
118 );
119
120 when(
121 mockApiService.getTimeline(
122 sort: anyNamed('sort'),
123 timeframe: anyNamed('timeframe'),
124 limit: anyNamed('limit'),
125 cursor: anyNamed('cursor'),
126 ),
127 ).thenAnswer((_) async => mockResponse);
128
129 await feedProvider.fetchTimeline(refresh: true);
130
131 expect(feedProvider.posts.length, 2);
132 expect(feedProvider.hasMore, true);
133 expect(feedProvider.error, null);
134 });
135
136 test('should handle network errors', () async {
137 when(
138 mockApiService.getTimeline(
139 sort: anyNamed('sort'),
140 timeframe: anyNamed('timeframe'),
141 limit: anyNamed('limit'),
142 cursor: anyNamed('cursor'),
143 ),
144 ).thenThrow(Exception('Network error'));
145
146 await feedProvider.fetchTimeline(refresh: true);
147
148 expect(feedProvider.error, isNotNull);
149 expect(feedProvider.isLoading, false);
150 });
151
152 test('should append posts when not refreshing', () async {
153 // First load
154 final firstResponse = TimelineResponse(
155 feed: [_createMockPost()],
156 cursor: 'cursor-1',
157 );
158
159 when(
160 mockApiService.getTimeline(
161 sort: anyNamed('sort'),
162 timeframe: anyNamed('timeframe'),
163 limit: anyNamed('limit'),
164 cursor: anyNamed('cursor'),
165 ),
166 ).thenAnswer((_) async => firstResponse);
167
168 await feedProvider.fetchTimeline(refresh: true);
169 expect(feedProvider.posts.length, 1);
170
171 // Second load (pagination)
172 final secondResponse = TimelineResponse(
173 feed: [_createMockPost()],
174 cursor: 'cursor-2',
175 );
176
177 when(
178 mockApiService.getTimeline(
179 sort: anyNamed('sort'),
180 timeframe: anyNamed('timeframe'),
181 limit: anyNamed('limit'),
182 cursor: 'cursor-1',
183 ),
184 ).thenAnswer((_) async => secondResponse);
185
186 await feedProvider.fetchTimeline();
187 expect(feedProvider.posts.length, 2);
188 });
189
190 test('should replace posts when refreshing', () async {
191 // First load
192 final firstResponse = TimelineResponse(
193 feed: [_createMockPost()],
194 cursor: 'cursor-1',
195 );
196
197 when(
198 mockApiService.getTimeline(
199 sort: anyNamed('sort'),
200 timeframe: anyNamed('timeframe'),
201 limit: anyNamed('limit'),
202 cursor: anyNamed('cursor'),
203 ),
204 ).thenAnswer((_) async => firstResponse);
205
206 await feedProvider.fetchTimeline(refresh: true);
207 expect(feedProvider.posts.length, 1);
208
209 // Refresh
210 final refreshResponse = TimelineResponse(
211 feed: [_createMockPost(), _createMockPost()],
212 cursor: 'cursor-2',
213 );
214
215 when(
216 mockApiService.getTimeline(
217 sort: anyNamed('sort'),
218 timeframe: anyNamed('timeframe'),
219 limit: anyNamed('limit'),
220 ),
221 ).thenAnswer((_) async => refreshResponse);
222
223 await feedProvider.fetchTimeline(refresh: true);
224 expect(feedProvider.posts.length, 2);
225 });
226
227 test('should set hasMore to false when no cursor', () async {
228 final response = TimelineResponse(feed: [_createMockPost()]);
229
230 when(
231 mockApiService.getTimeline(
232 sort: anyNamed('sort'),
233 timeframe: anyNamed('timeframe'),
234 limit: anyNamed('limit'),
235 cursor: anyNamed('cursor'),
236 ),
237 ).thenAnswer((_) async => response);
238
239 await feedProvider.fetchTimeline(refresh: true);
240
241 expect(feedProvider.hasMore, false);
242 });
243 });
244
245 group('fetchDiscover', () {
246 test('should fetch discover feed successfully', () async {
247 final mockResponse = TimelineResponse(
248 feed: [_createMockPost()],
249 cursor: 'next-cursor',
250 );
251
252 when(
253 mockApiService.getDiscover(
254 sort: anyNamed('sort'),
255 timeframe: anyNamed('timeframe'),
256 limit: anyNamed('limit'),
257 cursor: anyNamed('cursor'),
258 ),
259 ).thenAnswer((_) async => mockResponse);
260
261 await feedProvider.fetchDiscover(refresh: true);
262
263 expect(feedProvider.posts.length, 1);
264 expect(feedProvider.error, null);
265 });
266
267 test('should handle empty feed', () async {
268 final emptyResponse = TimelineResponse(feed: []);
269
270 when(
271 mockApiService.getDiscover(
272 sort: anyNamed('sort'),
273 timeframe: anyNamed('timeframe'),
274 limit: anyNamed('limit'),
275 cursor: anyNamed('cursor'),
276 ),
277 ).thenAnswer((_) async => emptyResponse);
278
279 await feedProvider.fetchDiscover(refresh: true);
280
281 expect(feedProvider.posts.isEmpty, true);
282 expect(feedProvider.hasMore, false);
283 });
284 });
285
286 group('loadMore', () {
287 test('should load more posts', () async {
288 when(mockAuthProvider.isAuthenticated).thenReturn(true);
289
290 // Initial load
291 final firstResponse = TimelineResponse(
292 feed: [_createMockPost()],
293 cursor: 'cursor-1',
294 );
295
296 when(
297 mockApiService.getTimeline(
298 sort: anyNamed('sort'),
299 timeframe: anyNamed('timeframe'),
300 limit: anyNamed('limit'),
301 cursor: anyNamed('cursor'),
302 ),
303 ).thenAnswer((_) async => firstResponse);
304
305 await feedProvider.setFeedType(FeedType.forYou);
306
307 // Load more
308 final secondResponse = TimelineResponse(
309 feed: [_createMockPost()],
310 cursor: 'cursor-2',
311 );
312
313 when(
314 mockApiService.getTimeline(
315 sort: anyNamed('sort'),
316 timeframe: anyNamed('timeframe'),
317 limit: anyNamed('limit'),
318 cursor: 'cursor-1',
319 ),
320 ).thenAnswer((_) async => secondResponse);
321
322 await feedProvider.loadMore();
323
324 expect(feedProvider.posts.length, 2);
325 });
326
327 test('should not load more if already loading', () async {
328 when(mockAuthProvider.isAuthenticated).thenReturn(true);
329
330 final response = TimelineResponse(
331 feed: [_createMockPost()],
332 cursor: 'cursor-1',
333 );
334
335 when(
336 mockApiService.getTimeline(
337 sort: anyNamed('sort'),
338 timeframe: anyNamed('timeframe'),
339 limit: anyNamed('limit'),
340 cursor: anyNamed('cursor'),
341 ),
342 ).thenAnswer((_) async => response);
343
344 await feedProvider.setFeedType(FeedType.forYou);
345 await feedProvider.loadMore();
346
347 // Should not make additional calls while loading
348 });
349
350 test('should not load more if hasMore is false', () async {
351 final response = TimelineResponse(feed: [_createMockPost()]);
352
353 when(
354 mockApiService.getTimeline(
355 sort: anyNamed('sort'),
356 timeframe: anyNamed('timeframe'),
357 limit: anyNamed('limit'),
358 cursor: anyNamed('cursor'),
359 ),
360 ).thenAnswer((_) async => response);
361
362 await feedProvider.fetchTimeline(refresh: true);
363 expect(feedProvider.hasMore, false);
364
365 await feedProvider.loadMore();
366 // Should not attempt to load more
367 });
368 });
369
370 group('retry', () {
371 test('should retry after error', () async {
372 when(mockAuthProvider.isAuthenticated).thenReturn(true);
373
374 // Simulate error
375 when(
376 mockApiService.getTimeline(
377 sort: anyNamed('sort'),
378 timeframe: anyNamed('timeframe'),
379 limit: anyNamed('limit'),
380 cursor: anyNamed('cursor'),
381 ),
382 ).thenThrow(Exception('Network error'));
383
384 await feedProvider.setFeedType(FeedType.forYou);
385 expect(feedProvider.error, isNotNull);
386
387 // Retry
388 final successResponse = TimelineResponse(
389 feed: [_createMockPost()],
390 cursor: 'cursor',
391 );
392
393 when(
394 mockApiService.getTimeline(
395 sort: anyNamed('sort'),
396 timeframe: anyNamed('timeframe'),
397 limit: anyNamed('limit'),
398 cursor: anyNamed('cursor'),
399 ),
400 ).thenAnswer((_) async => successResponse);
401
402 await feedProvider.retry();
403
404 expect(feedProvider.error, null);
405 expect(feedProvider.posts.length, 1);
406 });
407 });
408
409 group('State Management', () {
410 test('should notify listeners on state change', () async {
411 var notificationCount = 0;
412 feedProvider.addListener(() {
413 notificationCount++;
414 });
415
416 final mockResponse = TimelineResponse(
417 feed: [_createMockPost()],
418 cursor: 'cursor',
419 );
420
421 when(
422 mockApiService.getTimeline(
423 sort: anyNamed('sort'),
424 timeframe: anyNamed('timeframe'),
425 limit: anyNamed('limit'),
426 cursor: anyNamed('cursor'),
427 ),
428 ).thenAnswer((_) async => mockResponse);
429
430 await feedProvider.fetchTimeline(refresh: true);
431
432 expect(notificationCount, greaterThan(0));
433 });
434
435 test('should manage loading states correctly', () async {
436 final mockResponse = TimelineResponse(
437 feed: [_createMockPost()],
438 cursor: 'cursor',
439 );
440
441 when(
442 mockApiService.getTimeline(
443 sort: anyNamed('sort'),
444 timeframe: anyNamed('timeframe'),
445 limit: anyNamed('limit'),
446 cursor: anyNamed('cursor'),
447 ),
448 ).thenAnswer((_) async {
449 await Future.delayed(const Duration(milliseconds: 100));
450 return mockResponse;
451 });
452
453 final loadFuture = feedProvider.fetchTimeline(refresh: true);
454
455 // Should be loading
456 expect(feedProvider.isLoading, true);
457
458 await loadFuture;
459
460 // Should not be loading anymore
461 expect(feedProvider.isLoading, false);
462 });
463 });
464
465 group('Vote state initialization from viewer data', () {
466 late MockVoteProvider mockVoteProvider;
467 late FeedProvider feedProviderWithVotes;
468
469 setUp(() {
470 mockVoteProvider = MockVoteProvider();
471 feedProviderWithVotes = FeedProvider(
472 mockAuthProvider,
473 apiService: mockApiService,
474 voteProvider: mockVoteProvider,
475 );
476 });
477
478 tearDown(() {
479 feedProviderWithVotes.dispose();
480 });
481
482 test('should initialize vote state when viewer.vote is "up"', () async {
483 when(mockAuthProvider.isAuthenticated).thenReturn(true);
484
485 final mockResponse = TimelineResponse(
486 feed: [
487 _createMockPostWithViewer(
488 uri: 'at://did:plc:test/social.coves.post.record/1',
489 vote: 'up',
490 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
491 ),
492 ],
493 cursor: 'cursor',
494 );
495
496 when(
497 mockApiService.getTimeline(
498 sort: anyNamed('sort'),
499 timeframe: anyNamed('timeframe'),
500 limit: anyNamed('limit'),
501 cursor: anyNamed('cursor'),
502 ),
503 ).thenAnswer((_) async => mockResponse);
504
505 await feedProviderWithVotes.fetchTimeline(refresh: true);
506
507 verify(
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',
512 ),
513 ).called(1);
514 });
515
516 test('should initialize vote state when viewer.vote is "down"', () async {
517 when(mockAuthProvider.isAuthenticated).thenReturn(true);
518
519 final mockResponse = TimelineResponse(
520 feed: [
521 _createMockPostWithViewer(
522 uri: 'at://did:plc:test/social.coves.post.record/1',
523 vote: 'down',
524 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
525 ),
526 ],
527 cursor: 'cursor',
528 );
529
530 when(
531 mockApiService.getTimeline(
532 sort: anyNamed('sort'),
533 timeframe: anyNamed('timeframe'),
534 limit: anyNamed('limit'),
535 cursor: anyNamed('cursor'),
536 ),
537 ).thenAnswer((_) async => mockResponse);
538
539 await feedProviderWithVotes.fetchTimeline(refresh: true);
540
541 verify(
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',
546 ),
547 ).called(1);
548 });
549
550 test(
551 'should clear stale vote state when viewer.vote is null on refresh',
552 () async {
553 when(mockAuthProvider.isAuthenticated).thenReturn(true);
554
555 // Feed item with null vote (user removed vote on another device)
556 final mockResponse = TimelineResponse(
557 feed: [
558 _createMockPostWithViewer(
559 uri: 'at://did:plc:test/social.coves.post.record/1',
560 vote: null,
561 voteUri: null,
562 ),
563 ],
564 cursor: 'cursor',
565 );
566
567 when(
568 mockApiService.getTimeline(
569 sort: anyNamed('sort'),
570 timeframe: anyNamed('timeframe'),
571 limit: anyNamed('limit'),
572 cursor: anyNamed('cursor'),
573 ),
574 ).thenAnswer((_) async => mockResponse);
575
576 await feedProviderWithVotes.fetchTimeline(refresh: true);
577
578 // Should call setInitialVoteState with null to clear stale state
579 verify(
580 mockVoteProvider.setInitialVoteState(
581 postUri: 'at://did:plc:test/social.coves.post.record/1',
582 voteDirection: null,
583 voteUri: null,
584 ),
585 ).called(1);
586 },
587 );
588
589 test(
590 'should initialize vote state for all feed items including no viewer',
591 () async {
592 when(mockAuthProvider.isAuthenticated).thenReturn(true);
593
594 final mockResponse = TimelineResponse(
595 feed: [
596 _createMockPostWithViewer(
597 uri: 'at://did:plc:test/social.coves.post.record/1',
598 vote: 'up',
599 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
600 ),
601 _createMockPost(), // No viewer state
602 ],
603 cursor: 'cursor',
604 );
605
606 when(
607 mockApiService.getTimeline(
608 sort: anyNamed('sort'),
609 timeframe: anyNamed('timeframe'),
610 limit: anyNamed('limit'),
611 cursor: anyNamed('cursor'),
612 ),
613 ).thenAnswer((_) async => mockResponse);
614
615 await feedProviderWithVotes.fetchTimeline(refresh: true);
616
617 // Should be called for both posts
618 verify(
619 mockVoteProvider.setInitialVoteState(
620 postUri: anyNamed('postUri'),
621 voteDirection: anyNamed('voteDirection'),
622 voteUri: anyNamed('voteUri'),
623 ),
624 ).called(2);
625 },
626 );
627
628 test('should not initialize vote state when not authenticated', () async {
629 when(mockAuthProvider.isAuthenticated).thenReturn(false);
630
631 final mockResponse = TimelineResponse(
632 feed: [
633 _createMockPostWithViewer(
634 uri: 'at://did:plc:test/social.coves.post.record/1',
635 vote: 'up',
636 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
637 ),
638 ],
639 cursor: 'cursor',
640 );
641
642 when(
643 mockApiService.getDiscover(
644 sort: anyNamed('sort'),
645 timeframe: anyNamed('timeframe'),
646 limit: anyNamed('limit'),
647 cursor: anyNamed('cursor'),
648 ),
649 ).thenAnswer((_) async => mockResponse);
650
651 await feedProviderWithVotes.fetchDiscover(refresh: true);
652
653 // Should NOT call setInitialVoteState when not authenticated
654 verifyNever(
655 mockVoteProvider.setInitialVoteState(
656 postUri: anyNamed('postUri'),
657 voteDirection: anyNamed('voteDirection'),
658 voteUri: anyNamed('voteUri'),
659 ),
660 );
661 });
662 });
663 });
664}
665
666// Helper function to create mock posts
667FeedViewPost _createMockPost() {
668 return FeedViewPost(
669 post: PostView(
670 uri: 'at://did:plc:test/app.bsky.feed.post/test',
671 cid: 'test-cid',
672 rkey: 'test-rkey',
673 author: AuthorView(
674 did: 'did:plc:author',
675 handle: 'test.user',
676 displayName: 'Test User',
677 ),
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'),
681 text: 'Test body',
682 title: 'Test Post',
683 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
684 facets: [],
685 ),
686 );
687}
688
689// Helper function to create mock posts with viewer state
690FeedViewPost _createMockPostWithViewer({
691 required String uri,
692 String? vote,
693 String? voteUri,
694}) {
695 return FeedViewPost(
696 post: PostView(
697 uri: uri,
698 cid: 'test-cid',
699 rkey: 'test-rkey',
700 author: AuthorView(
701 did: 'did:plc:author',
702 handle: 'test.user',
703 displayName: 'Test User',
704 ),
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'),
708 text: 'Test body',
709 title: 'Test Post',
710 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
711 facets: [],
712 viewer: ViewerState(vote: vote, voteUri: voteUri),
713 ),
714 );
715}