Main coves client
1import 'package:coves_flutter/models/comment.dart';
2import 'package:coves_flutter/models/post.dart';
3import 'package:coves_flutter/providers/auth_provider.dart';
4import 'package:coves_flutter/providers/comments_provider.dart';
5import 'package:coves_flutter/providers/vote_provider.dart';
6import 'package:coves_flutter/services/coves_api_service.dart';
7import 'package:coves_flutter/services/vote_service.dart';
8import 'package:flutter_test/flutter_test.dart';
9import 'package:mockito/annotations.dart';
10import 'package:mockito/mockito.dart';
11
12import 'comments_provider_test.mocks.dart';
13
14// Generate mocks for dependencies
15@GenerateMocks([AuthProvider, CovesApiService, VoteProvider, VoteService])
16void main() {
17 TestWidgetsFlutterBinding.ensureInitialized();
18
19 group('CommentsProvider', () {
20 late CommentsProvider commentsProvider;
21 late MockAuthProvider mockAuthProvider;
22 late MockCovesApiService mockApiService;
23 late MockVoteProvider mockVoteProvider;
24 late MockVoteService mockVoteService;
25
26 setUp(() {
27 mockAuthProvider = MockAuthProvider();
28 mockApiService = MockCovesApiService();
29 mockVoteProvider = MockVoteProvider();
30 mockVoteService = MockVoteService();
31
32 // Default: user is authenticated
33 when(mockAuthProvider.isAuthenticated).thenReturn(true);
34 when(
35 mockAuthProvider.getAccessToken(),
36 ).thenAnswer((_) async => 'test-token');
37
38 commentsProvider = CommentsProvider(
39 mockAuthProvider,
40 apiService: mockApiService,
41 voteProvider: mockVoteProvider,
42 voteService: mockVoteService,
43 );
44 });
45
46 tearDown(() {
47 commentsProvider.dispose();
48 });
49
50 group('loadComments', () {
51 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
52
53 test('should load comments successfully', () async {
54 final mockComments = [
55 _createMockThreadComment('comment1'),
56 _createMockThreadComment('comment2'),
57 ];
58
59 final mockResponse = CommentsResponse(
60 post: {},
61 comments: mockComments,
62 cursor: 'next-cursor',
63 );
64
65 when(
66 mockApiService.getComments(
67 postUri: anyNamed('postUri'),
68 sort: anyNamed('sort'),
69 timeframe: anyNamed('timeframe'),
70 depth: anyNamed('depth'),
71 limit: anyNamed('limit'),
72 cursor: anyNamed('cursor'),
73 ),
74 ).thenAnswer((_) async => mockResponse);
75
76 when(
77 mockVoteService.getUserVotes(),
78 ).thenAnswer((_) async => <String, VoteInfo>{});
79
80 await commentsProvider.loadComments(
81 postUri: testPostUri,
82 refresh: true,
83 );
84
85 expect(commentsProvider.comments.length, 2);
86 expect(commentsProvider.hasMore, true);
87 expect(commentsProvider.error, null);
88 expect(commentsProvider.isLoading, false);
89 });
90
91 test('should handle empty comments response', () async {
92 final mockResponse = CommentsResponse(post: {}, comments: []);
93
94 when(
95 mockApiService.getComments(
96 postUri: anyNamed('postUri'),
97 sort: anyNamed('sort'),
98 timeframe: anyNamed('timeframe'),
99 depth: anyNamed('depth'),
100 limit: anyNamed('limit'),
101 cursor: anyNamed('cursor'),
102 ),
103 ).thenAnswer((_) async => mockResponse);
104
105 when(
106 mockVoteService.getUserVotes(),
107 ).thenAnswer((_) async => <String, VoteInfo>{});
108
109 await commentsProvider.loadComments(
110 postUri: testPostUri,
111 refresh: true,
112 );
113
114 expect(commentsProvider.comments.isEmpty, true);
115 expect(commentsProvider.hasMore, false);
116 expect(commentsProvider.error, null);
117 });
118
119 test('should handle network errors', () async {
120 when(
121 mockApiService.getComments(
122 postUri: anyNamed('postUri'),
123 sort: anyNamed('sort'),
124 timeframe: anyNamed('timeframe'),
125 depth: anyNamed('depth'),
126 limit: anyNamed('limit'),
127 cursor: anyNamed('cursor'),
128 ),
129 ).thenThrow(Exception('Network error'));
130
131 await commentsProvider.loadComments(
132 postUri: testPostUri,
133 refresh: true,
134 );
135
136 expect(commentsProvider.error, isNotNull);
137 expect(commentsProvider.error, contains('Network error'));
138 expect(commentsProvider.isLoading, false);
139 expect(commentsProvider.comments.isEmpty, true);
140 });
141
142 test('should handle timeout errors', () async {
143 when(
144 mockApiService.getComments(
145 postUri: anyNamed('postUri'),
146 sort: anyNamed('sort'),
147 timeframe: anyNamed('timeframe'),
148 depth: anyNamed('depth'),
149 limit: anyNamed('limit'),
150 cursor: anyNamed('cursor'),
151 ),
152 ).thenThrow(Exception('TimeoutException: Request timed out'));
153
154 await commentsProvider.loadComments(
155 postUri: testPostUri,
156 refresh: true,
157 );
158
159 expect(commentsProvider.error, isNotNull);
160 expect(commentsProvider.isLoading, false);
161 });
162
163 test('should append comments when not refreshing', () async {
164 // First load
165 final firstResponse = CommentsResponse(
166 post: {},
167 comments: [_createMockThreadComment('comment1')],
168 cursor: 'cursor-1',
169 );
170
171 when(
172 mockApiService.getComments(
173 postUri: anyNamed('postUri'),
174 sort: anyNamed('sort'),
175 timeframe: anyNamed('timeframe'),
176 depth: anyNamed('depth'),
177 limit: anyNamed('limit'),
178 cursor: anyNamed('cursor'),
179 ),
180 ).thenAnswer((_) async => firstResponse);
181
182 when(
183 mockVoteService.getUserVotes(),
184 ).thenAnswer((_) async => <String, VoteInfo>{});
185
186 await commentsProvider.loadComments(
187 postUri: testPostUri,
188 refresh: true,
189 );
190
191 expect(commentsProvider.comments.length, 1);
192
193 // Second load (pagination)
194 final secondResponse = CommentsResponse(
195 post: {},
196 comments: [_createMockThreadComment('comment2')],
197 cursor: 'cursor-2',
198 );
199
200 when(
201 mockApiService.getComments(
202 postUri: anyNamed('postUri'),
203 sort: anyNamed('sort'),
204 timeframe: anyNamed('timeframe'),
205 depth: anyNamed('depth'),
206 limit: anyNamed('limit'),
207 cursor: 'cursor-1',
208 ),
209 ).thenAnswer((_) async => secondResponse);
210
211 await commentsProvider.loadComments(postUri: testPostUri);
212
213 expect(commentsProvider.comments.length, 2);
214 expect(commentsProvider.comments[0].comment.uri, 'comment1');
215 expect(commentsProvider.comments[1].comment.uri, 'comment2');
216 });
217
218 test('should replace comments when refreshing', () async {
219 // First load
220 final firstResponse = CommentsResponse(
221 post: {},
222 comments: [_createMockThreadComment('comment1')],
223 cursor: 'cursor-1',
224 );
225
226 when(
227 mockApiService.getComments(
228 postUri: anyNamed('postUri'),
229 sort: anyNamed('sort'),
230 timeframe: anyNamed('timeframe'),
231 depth: anyNamed('depth'),
232 limit: anyNamed('limit'),
233 cursor: anyNamed('cursor'),
234 ),
235 ).thenAnswer((_) async => firstResponse);
236
237 when(
238 mockVoteService.getUserVotes(),
239 ).thenAnswer((_) async => <String, VoteInfo>{});
240
241 await commentsProvider.loadComments(
242 postUri: testPostUri,
243 refresh: true,
244 );
245
246 expect(commentsProvider.comments.length, 1);
247
248 // Refresh with new data
249 final refreshResponse = CommentsResponse(
250 post: {},
251 comments: [
252 _createMockThreadComment('comment2'),
253 _createMockThreadComment('comment3'),
254 ],
255 cursor: 'cursor-2',
256 );
257
258 when(
259 mockApiService.getComments(
260 postUri: anyNamed('postUri'),
261 sort: anyNamed('sort'),
262 timeframe: anyNamed('timeframe'),
263 depth: anyNamed('depth'),
264 limit: anyNamed('limit'),
265 ),
266 ).thenAnswer((_) async => refreshResponse);
267
268 await commentsProvider.loadComments(
269 postUri: testPostUri,
270 refresh: true,
271 );
272
273 expect(commentsProvider.comments.length, 2);
274 expect(commentsProvider.comments[0].comment.uri, 'comment2');
275 expect(commentsProvider.comments[1].comment.uri, 'comment3');
276 });
277
278 test('should set hasMore to false when no cursor', () async {
279 final response = CommentsResponse(
280 post: {},
281 comments: [_createMockThreadComment('comment1')],
282 );
283
284 when(
285 mockApiService.getComments(
286 postUri: anyNamed('postUri'),
287 sort: anyNamed('sort'),
288 timeframe: anyNamed('timeframe'),
289 depth: anyNamed('depth'),
290 limit: anyNamed('limit'),
291 cursor: anyNamed('cursor'),
292 ),
293 ).thenAnswer((_) async => response);
294
295 when(
296 mockVoteService.getUserVotes(),
297 ).thenAnswer((_) async => <String, VoteInfo>{});
298
299 await commentsProvider.loadComments(
300 postUri: testPostUri,
301 refresh: true,
302 );
303
304 expect(commentsProvider.hasMore, false);
305 });
306
307 test('should reset state when loading different post', () async {
308 // Load first post
309 final firstResponse = CommentsResponse(
310 post: {},
311 comments: [_createMockThreadComment('comment1')],
312 cursor: 'cursor-1',
313 );
314
315 when(
316 mockApiService.getComments(
317 postUri: anyNamed('postUri'),
318 sort: anyNamed('sort'),
319 timeframe: anyNamed('timeframe'),
320 depth: anyNamed('depth'),
321 limit: anyNamed('limit'),
322 cursor: anyNamed('cursor'),
323 ),
324 ).thenAnswer((_) async => firstResponse);
325
326 when(
327 mockVoteService.getUserVotes(),
328 ).thenAnswer((_) async => <String, VoteInfo>{});
329
330 await commentsProvider.loadComments(
331 postUri: testPostUri,
332 refresh: true,
333 );
334
335 expect(commentsProvider.comments.length, 1);
336
337 // Load different post
338 const differentPostUri =
339 'at://did:plc:test/social.coves.post.record/456';
340 final secondResponse = CommentsResponse(
341 post: {},
342 comments: [_createMockThreadComment('comment2')],
343 );
344
345 when(
346 mockApiService.getComments(
347 postUri: differentPostUri,
348 sort: anyNamed('sort'),
349 timeframe: anyNamed('timeframe'),
350 depth: anyNamed('depth'),
351 limit: anyNamed('limit'),
352 cursor: anyNamed('cursor'),
353 ),
354 ).thenAnswer((_) async => secondResponse);
355
356 await commentsProvider.loadComments(
357 postUri: differentPostUri,
358 refresh: true,
359 );
360
361 // Should have reset and loaded new comments
362 expect(commentsProvider.comments.length, 1);
363 expect(commentsProvider.comments[0].comment.uri, 'comment2');
364 });
365
366 test('should not load when already loading', () async {
367 final response = CommentsResponse(
368 post: {},
369 comments: [_createMockThreadComment('comment1')],
370 cursor: 'cursor',
371 );
372
373 when(
374 mockApiService.getComments(
375 postUri: anyNamed('postUri'),
376 sort: anyNamed('sort'),
377 timeframe: anyNamed('timeframe'),
378 depth: anyNamed('depth'),
379 limit: anyNamed('limit'),
380 cursor: anyNamed('cursor'),
381 ),
382 ).thenAnswer((_) async {
383 await Future.delayed(const Duration(milliseconds: 100));
384 return response;
385 });
386
387 when(
388 mockVoteService.getUserVotes(),
389 ).thenAnswer((_) async => <String, VoteInfo>{});
390
391 // Start first load
392 final firstFuture = commentsProvider.loadComments(
393 postUri: testPostUri,
394 refresh: true,
395 );
396
397 // Try to load again while still loading - should schedule a refresh
398 await commentsProvider.loadComments(
399 postUri: testPostUri,
400 refresh: true,
401 );
402
403 await firstFuture;
404 // Wait a bit for the pending refresh to execute
405 await Future.delayed(const Duration(milliseconds: 200));
406
407 // Should have called API twice - once for initial load, once for pending refresh
408 verify(
409 mockApiService.getComments(
410 postUri: anyNamed('postUri'),
411 sort: anyNamed('sort'),
412 timeframe: anyNamed('timeframe'),
413 depth: anyNamed('depth'),
414 limit: anyNamed('limit'),
415 cursor: anyNamed('cursor'),
416 ),
417 ).called(2);
418 });
419
420 test('should load vote state when authenticated', () async {
421 final mockComments = [_createMockThreadComment('comment1')];
422
423 final mockResponse = CommentsResponse(post: {}, comments: mockComments);
424
425 when(
426 mockApiService.getComments(
427 postUri: anyNamed('postUri'),
428 sort: anyNamed('sort'),
429 timeframe: anyNamed('timeframe'),
430 depth: anyNamed('depth'),
431 limit: anyNamed('limit'),
432 cursor: anyNamed('cursor'),
433 ),
434 ).thenAnswer((_) async => mockResponse);
435
436 final mockUserVotes = <String, VoteInfo>{
437 'comment1': const VoteInfo(
438 voteUri: 'at://did:plc:test/social.coves.feed.vote/123',
439 direction: 'up',
440 rkey: '123',
441 ),
442 };
443
444 when(
445 mockVoteService.getUserVotes(),
446 ).thenAnswer((_) async => mockUserVotes);
447 when(mockVoteProvider.loadInitialVotes(any)).thenReturn(null);
448
449 await commentsProvider.loadComments(
450 postUri: testPostUri,
451 refresh: true,
452 );
453
454 verify(mockVoteService.getUserVotes()).called(1);
455 verify(mockVoteProvider.loadInitialVotes(mockUserVotes)).called(1);
456 });
457
458 test('should not load vote state when not authenticated', () async {
459 when(mockAuthProvider.isAuthenticated).thenReturn(false);
460
461 final mockResponse = CommentsResponse(
462 post: {},
463 comments: [_createMockThreadComment('comment1')],
464 );
465
466 when(
467 mockApiService.getComments(
468 postUri: anyNamed('postUri'),
469 sort: anyNamed('sort'),
470 timeframe: anyNamed('timeframe'),
471 depth: anyNamed('depth'),
472 limit: anyNamed('limit'),
473 cursor: anyNamed('cursor'),
474 ),
475 ).thenAnswer((_) async => mockResponse);
476
477 await commentsProvider.loadComments(
478 postUri: testPostUri,
479 refresh: true,
480 );
481
482 verifyNever(mockVoteService.getUserVotes());
483 verifyNever(mockVoteProvider.loadInitialVotes(any));
484 });
485
486 test('should continue loading comments if vote loading fails', () async {
487 final mockComments = [_createMockThreadComment('comment1')];
488
489 final mockResponse = CommentsResponse(post: {}, comments: mockComments);
490
491 when(
492 mockApiService.getComments(
493 postUri: anyNamed('postUri'),
494 sort: anyNamed('sort'),
495 timeframe: anyNamed('timeframe'),
496 depth: anyNamed('depth'),
497 limit: anyNamed('limit'),
498 cursor: anyNamed('cursor'),
499 ),
500 ).thenAnswer((_) async => mockResponse);
501
502 when(
503 mockVoteService.getUserVotes(),
504 ).thenThrow(Exception('Vote service error'));
505
506 await commentsProvider.loadComments(
507 postUri: testPostUri,
508 refresh: true,
509 );
510
511 // Comments should still be loaded despite vote error
512 expect(commentsProvider.comments.length, 1);
513 expect(commentsProvider.error, null);
514 });
515 });
516
517 group('setSortOption', () {
518 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
519
520 test('should change sort option and reload comments', () async {
521 // Initial load with default sort
522 final initialResponse = CommentsResponse(
523 post: {},
524 comments: [_createMockThreadComment('comment1')],
525 );
526
527 when(
528 mockApiService.getComments(
529 postUri: anyNamed('postUri'),
530 timeframe: anyNamed('timeframe'),
531 depth: anyNamed('depth'),
532 limit: anyNamed('limit'),
533 cursor: anyNamed('cursor'),
534 ),
535 ).thenAnswer((_) async => initialResponse);
536
537 when(
538 mockVoteService.getUserVotes(),
539 ).thenAnswer((_) async => <String, VoteInfo>{});
540
541 await commentsProvider.loadComments(
542 postUri: testPostUri,
543 refresh: true,
544 );
545
546 expect(commentsProvider.sort, 'hot');
547
548 // Change sort option
549 final newSortResponse = CommentsResponse(
550 post: {},
551 comments: [
552 _createMockThreadComment('comment2'),
553 _createMockThreadComment('comment3'),
554 ],
555 );
556
557 when(
558 mockApiService.getComments(
559 postUri: anyNamed('postUri'),
560 sort: 'new',
561 timeframe: anyNamed('timeframe'),
562 depth: anyNamed('depth'),
563 limit: anyNamed('limit'),
564 ),
565 ).thenAnswer((_) async => newSortResponse);
566
567 await commentsProvider.setSortOption('new');
568
569 expect(commentsProvider.sort, 'new');
570 verify(
571 mockApiService.getComments(
572 postUri: testPostUri,
573 sort: 'new',
574 timeframe: anyNamed('timeframe'),
575 depth: anyNamed('depth'),
576 limit: anyNamed('limit'),
577 ),
578 ).called(1);
579 });
580
581 test('should not reload if sort option is same', () async {
582 final response = CommentsResponse(
583 post: {},
584 comments: [_createMockThreadComment('comment1')],
585 );
586
587 when(
588 mockApiService.getComments(
589 postUri: anyNamed('postUri'),
590 sort: anyNamed('sort'),
591 timeframe: anyNamed('timeframe'),
592 depth: anyNamed('depth'),
593 limit: anyNamed('limit'),
594 cursor: anyNamed('cursor'),
595 ),
596 ).thenAnswer((_) async => response);
597
598 when(
599 mockVoteService.getUserVotes(),
600 ).thenAnswer((_) async => <String, VoteInfo>{});
601
602 await commentsProvider.loadComments(
603 postUri: testPostUri,
604 refresh: true,
605 );
606
607 // Try to set same sort option
608 await commentsProvider.setSortOption('hot');
609
610 // Should only have been called once (initial load)
611 verify(
612 mockApiService.getComments(
613 postUri: anyNamed('postUri'),
614 timeframe: anyNamed('timeframe'),
615 depth: anyNamed('depth'),
616 limit: anyNamed('limit'),
617 cursor: anyNamed('cursor'),
618 ),
619 ).called(1);
620 });
621 });
622
623 group('refreshComments', () {
624 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
625
626 test('should refresh comments for current post', () async {
627 final initialResponse = CommentsResponse(
628 post: {},
629 comments: [_createMockThreadComment('comment1')],
630 cursor: 'cursor-1',
631 );
632
633 when(
634 mockApiService.getComments(
635 postUri: anyNamed('postUri'),
636 sort: anyNamed('sort'),
637 timeframe: anyNamed('timeframe'),
638 depth: anyNamed('depth'),
639 limit: anyNamed('limit'),
640 cursor: anyNamed('cursor'),
641 ),
642 ).thenAnswer((_) async => initialResponse);
643
644 when(
645 mockVoteService.getUserVotes(),
646 ).thenAnswer((_) async => <String, VoteInfo>{});
647
648 await commentsProvider.loadComments(
649 postUri: testPostUri,
650 refresh: true,
651 );
652
653 expect(commentsProvider.comments.length, 1);
654
655 // Refresh
656 final refreshResponse = CommentsResponse(
657 post: {},
658 comments: [
659 _createMockThreadComment('comment2'),
660 _createMockThreadComment('comment3'),
661 ],
662 );
663
664 when(
665 mockApiService.getComments(
666 postUri: testPostUri,
667 sort: anyNamed('sort'),
668 timeframe: anyNamed('timeframe'),
669 depth: anyNamed('depth'),
670 limit: anyNamed('limit'),
671 ),
672 ).thenAnswer((_) async => refreshResponse);
673
674 await commentsProvider.refreshComments();
675
676 expect(commentsProvider.comments.length, 2);
677 });
678
679 test('should not refresh if no post loaded', () async {
680 await commentsProvider.refreshComments();
681
682 verifyNever(
683 mockApiService.getComments(
684 postUri: anyNamed('postUri'),
685 sort: anyNamed('sort'),
686 timeframe: anyNamed('timeframe'),
687 depth: anyNamed('depth'),
688 limit: anyNamed('limit'),
689 cursor: anyNamed('cursor'),
690 ),
691 );
692 });
693 });
694
695 group('loadMoreComments', () {
696 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
697
698 test('should load more comments when hasMore is true', () async {
699 // Initial load
700 final initialResponse = CommentsResponse(
701 post: {},
702 comments: [_createMockThreadComment('comment1')],
703 cursor: 'cursor-1',
704 );
705
706 when(
707 mockApiService.getComments(
708 postUri: anyNamed('postUri'),
709 sort: anyNamed('sort'),
710 timeframe: anyNamed('timeframe'),
711 depth: anyNamed('depth'),
712 limit: anyNamed('limit'),
713 cursor: anyNamed('cursor'),
714 ),
715 ).thenAnswer((_) async => initialResponse);
716
717 when(
718 mockVoteService.getUserVotes(),
719 ).thenAnswer((_) async => <String, VoteInfo>{});
720
721 await commentsProvider.loadComments(
722 postUri: testPostUri,
723 refresh: true,
724 );
725
726 expect(commentsProvider.hasMore, true);
727
728 // Load more
729 final moreResponse = CommentsResponse(
730 post: {},
731 comments: [_createMockThreadComment('comment2')],
732 );
733
734 when(
735 mockApiService.getComments(
736 postUri: testPostUri,
737 sort: anyNamed('sort'),
738 timeframe: anyNamed('timeframe'),
739 depth: anyNamed('depth'),
740 limit: anyNamed('limit'),
741 cursor: 'cursor-1',
742 ),
743 ).thenAnswer((_) async => moreResponse);
744
745 await commentsProvider.loadMoreComments();
746
747 expect(commentsProvider.comments.length, 2);
748 expect(commentsProvider.hasMore, false);
749 });
750
751 test('should not load more when hasMore is false', () async {
752 final response = CommentsResponse(
753 post: {},
754 comments: [_createMockThreadComment('comment1')],
755 );
756
757 when(
758 mockApiService.getComments(
759 postUri: anyNamed('postUri'),
760 sort: anyNamed('sort'),
761 timeframe: anyNamed('timeframe'),
762 depth: anyNamed('depth'),
763 limit: anyNamed('limit'),
764 cursor: anyNamed('cursor'),
765 ),
766 ).thenAnswer((_) async => response);
767
768 when(
769 mockVoteService.getUserVotes(),
770 ).thenAnswer((_) async => <String, VoteInfo>{});
771
772 await commentsProvider.loadComments(
773 postUri: testPostUri,
774 refresh: true,
775 );
776
777 expect(commentsProvider.hasMore, false);
778
779 // Try to load more
780 await commentsProvider.loadMoreComments();
781
782 // Should only have been called once (initial load)
783 verify(
784 mockApiService.getComments(
785 postUri: anyNamed('postUri'),
786 sort: anyNamed('sort'),
787 timeframe: anyNamed('timeframe'),
788 depth: anyNamed('depth'),
789 limit: anyNamed('limit'),
790 cursor: anyNamed('cursor'),
791 ),
792 ).called(1);
793 });
794
795 test('should not load more if no post loaded', () async {
796 await commentsProvider.loadMoreComments();
797
798 verifyNever(
799 mockApiService.getComments(
800 postUri: anyNamed('postUri'),
801 sort: anyNamed('sort'),
802 timeframe: anyNamed('timeframe'),
803 depth: anyNamed('depth'),
804 limit: anyNamed('limit'),
805 cursor: anyNamed('cursor'),
806 ),
807 );
808 });
809 });
810
811 group('retry', () {
812 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
813
814 test('should retry after error', () async {
815 // Simulate error
816 when(
817 mockApiService.getComments(
818 postUri: anyNamed('postUri'),
819 sort: anyNamed('sort'),
820 timeframe: anyNamed('timeframe'),
821 depth: anyNamed('depth'),
822 limit: anyNamed('limit'),
823 cursor: anyNamed('cursor'),
824 ),
825 ).thenThrow(Exception('Network error'));
826
827 await commentsProvider.loadComments(
828 postUri: testPostUri,
829 refresh: true,
830 );
831
832 expect(commentsProvider.error, isNotNull);
833
834 // Retry with success
835 final successResponse = CommentsResponse(
836 post: {},
837 comments: [_createMockThreadComment('comment1')],
838 );
839
840 when(
841 mockApiService.getComments(
842 postUri: anyNamed('postUri'),
843 sort: anyNamed('sort'),
844 timeframe: anyNamed('timeframe'),
845 depth: anyNamed('depth'),
846 limit: anyNamed('limit'),
847 cursor: anyNamed('cursor'),
848 ),
849 ).thenAnswer((_) async => successResponse);
850
851 when(
852 mockVoteService.getUserVotes(),
853 ).thenAnswer((_) async => <String, VoteInfo>{});
854
855 await commentsProvider.retry();
856
857 expect(commentsProvider.error, null);
858 expect(commentsProvider.comments.length, 1);
859 });
860 });
861
862 group('Auth state changes', () {
863 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
864
865 test('should clear comments on sign-out', () async {
866 final response = CommentsResponse(
867 post: {},
868 comments: [_createMockThreadComment('comment1')],
869 );
870
871 when(
872 mockApiService.getComments(
873 postUri: anyNamed('postUri'),
874 sort: anyNamed('sort'),
875 timeframe: anyNamed('timeframe'),
876 depth: anyNamed('depth'),
877 limit: anyNamed('limit'),
878 cursor: anyNamed('cursor'),
879 ),
880 ).thenAnswer((_) async => response);
881
882 when(
883 mockVoteService.getUserVotes(),
884 ).thenAnswer((_) async => <String, VoteInfo>{});
885
886 await commentsProvider.loadComments(
887 postUri: testPostUri,
888 refresh: true,
889 );
890
891 expect(commentsProvider.comments.length, 1);
892
893 // Simulate sign-out
894 when(mockAuthProvider.isAuthenticated).thenReturn(false);
895 // Trigger listener manually since we're using a mock
896 commentsProvider.reset();
897
898 expect(commentsProvider.comments.isEmpty, true);
899 });
900 });
901
902 group('Time updates', () {
903 test('should start time updates when comments are loaded', () async {
904 final response = CommentsResponse(
905 post: {},
906 comments: [_createMockThreadComment('comment1')],
907 );
908
909 when(
910 mockApiService.getComments(
911 postUri: anyNamed('postUri'),
912 sort: anyNamed('sort'),
913 timeframe: anyNamed('timeframe'),
914 depth: anyNamed('depth'),
915 limit: anyNamed('limit'),
916 cursor: anyNamed('cursor'),
917 ),
918 ).thenAnswer((_) async => response);
919
920 when(
921 mockVoteService.getUserVotes(),
922 ).thenAnswer((_) async => <String, VoteInfo>{});
923
924 expect(commentsProvider.currentTimeNotifier.value, null);
925
926 await commentsProvider.loadComments(
927 postUri: 'at://did:plc:test/social.coves.post.record/123',
928 refresh: true,
929 );
930
931 expect(commentsProvider.currentTimeNotifier.value, isNotNull);
932 });
933
934 test('should stop time updates on dispose', () async {
935 final response = CommentsResponse(
936 post: {},
937 comments: [_createMockThreadComment('comment1')],
938 );
939
940 when(
941 mockApiService.getComments(
942 postUri: anyNamed('postUri'),
943 sort: anyNamed('sort'),
944 timeframe: anyNamed('timeframe'),
945 depth: anyNamed('depth'),
946 limit: anyNamed('limit'),
947 cursor: anyNamed('cursor'),
948 ),
949 ).thenAnswer((_) async => response);
950
951 when(
952 mockVoteService.getUserVotes(),
953 ).thenAnswer((_) async => <String, VoteInfo>{});
954
955 await commentsProvider.loadComments(
956 postUri: 'at://did:plc:test/social.coves.post.record/123',
957 refresh: true,
958 );
959
960 expect(commentsProvider.currentTimeNotifier.value, isNotNull);
961
962 // Call stopTimeUpdates to stop the timer
963 commentsProvider.stopTimeUpdates();
964
965 // After stopping time updates, value should be null
966 expect(commentsProvider.currentTimeNotifier.value, null);
967 });
968 });
969
970 group('State management', () {
971 test('should notify listeners on state change', () async {
972 var notificationCount = 0;
973 commentsProvider.addListener(() {
974 notificationCount++;
975 });
976
977 final response = CommentsResponse(
978 post: {},
979 comments: [_createMockThreadComment('comment1')],
980 );
981
982 when(
983 mockApiService.getComments(
984 postUri: anyNamed('postUri'),
985 sort: anyNamed('sort'),
986 timeframe: anyNamed('timeframe'),
987 depth: anyNamed('depth'),
988 limit: anyNamed('limit'),
989 cursor: anyNamed('cursor'),
990 ),
991 ).thenAnswer((_) async => response);
992
993 when(
994 mockVoteService.getUserVotes(),
995 ).thenAnswer((_) async => <String, VoteInfo>{});
996
997 await commentsProvider.loadComments(
998 postUri: 'at://did:plc:test/social.coves.post.record/123',
999 refresh: true,
1000 );
1001
1002 expect(notificationCount, greaterThan(0));
1003 });
1004
1005 test('should manage loading states correctly', () async {
1006 final response = CommentsResponse(
1007 post: {},
1008 comments: [_createMockThreadComment('comment1')],
1009 );
1010
1011 when(
1012 mockApiService.getComments(
1013 postUri: anyNamed('postUri'),
1014 sort: anyNamed('sort'),
1015 timeframe: anyNamed('timeframe'),
1016 depth: anyNamed('depth'),
1017 limit: anyNamed('limit'),
1018 cursor: anyNamed('cursor'),
1019 ),
1020 ).thenAnswer((_) async {
1021 await Future.delayed(const Duration(milliseconds: 100));
1022 return response;
1023 });
1024
1025 when(
1026 mockVoteService.getUserVotes(),
1027 ).thenAnswer((_) async => <String, VoteInfo>{});
1028
1029 final loadFuture = commentsProvider.loadComments(
1030 postUri: 'at://did:plc:test/social.coves.post.record/123',
1031 refresh: true,
1032 );
1033
1034 // Should be loading
1035 expect(commentsProvider.isLoading, true);
1036
1037 await loadFuture;
1038
1039 // Should not be loading anymore
1040 expect(commentsProvider.isLoading, false);
1041 });
1042 });
1043 });
1044}
1045
1046// Helper function to create mock comments
1047ThreadViewComment _createMockThreadComment(String uri) {
1048 return ThreadViewComment(
1049 comment: CommentView(
1050 uri: uri,
1051 cid: 'cid-$uri',
1052 content: 'Test comment content',
1053 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
1054 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
1055 author: AuthorView(
1056 did: 'did:plc:author',
1057 handle: 'test.user',
1058 displayName: 'Test User',
1059 ),
1060 post: CommentRef(
1061 uri: 'at://did:plc:test/social.coves.post.record/123',
1062 cid: 'post-cid',
1063 ),
1064 stats: CommentStats(score: 10, upvotes: 12, downvotes: 2),
1065 ),
1066 );
1067}