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