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