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