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/api_exceptions.dart';
7import 'package:coves_flutter/services/comment_service.dart';
8import 'package:coves_flutter/services/coves_api_service.dart';
9import 'package:flutter_test/flutter_test.dart';
10import 'package:mockito/annotations.dart';
11import 'package:mockito/mockito.dart';
12
13import 'comments_provider_test.mocks.dart';
14
15// Generate mocks for dependencies
16@GenerateMocks([AuthProvider, CovesApiService, VoteProvider, CommentService])
17void main() {
18 TestWidgetsFlutterBinding.ensureInitialized();
19
20 group('CommentsProvider', () {
21 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
22 const testPostCid = 'test-post-cid';
23
24 late CommentsProvider commentsProvider;
25 late MockAuthProvider mockAuthProvider;
26 late MockCovesApiService mockApiService;
27 late MockVoteProvider mockVoteProvider;
28
29 setUp(() {
30 mockAuthProvider = MockAuthProvider();
31 mockApiService = MockCovesApiService();
32 mockVoteProvider = MockVoteProvider();
33
34 // Default: user is authenticated
35 when(mockAuthProvider.isAuthenticated).thenReturn(true);
36 when(
37 mockAuthProvider.getAccessToken(),
38 ).thenAnswer((_) async => 'test-token');
39
40 commentsProvider = CommentsProvider(
41 mockAuthProvider,
42 postUri: testPostUri,
43 postCid: testPostCid,
44 apiService: mockApiService,
45 voteProvider: mockVoteProvider,
46 );
47 });
48
49 tearDown(() {
50 commentsProvider.dispose();
51 });
52
53 group('loadComments', () {
54 test('should load comments successfully', () async {
55 final mockComments = [
56 _createMockThreadComment('comment1'),
57 _createMockThreadComment('comment2'),
58 ];
59
60 final mockResponse = CommentsResponse(
61 post: {},
62 comments: mockComments,
63 cursor: 'next-cursor',
64 );
65
66 when(
67 mockApiService.getComments(
68 postUri: anyNamed('postUri'),
69 sort: anyNamed('sort'),
70 timeframe: anyNamed('timeframe'),
71 depth: anyNamed('depth'),
72 limit: anyNamed('limit'),
73 cursor: anyNamed('cursor'),
74 ),
75 ).thenAnswer((_) async => mockResponse);
76
77 await commentsProvider.loadComments(refresh: true);
78
79 expect(commentsProvider.comments.length, 2);
80 expect(commentsProvider.hasMore, true);
81 expect(commentsProvider.error, null);
82 expect(commentsProvider.isLoading, false);
83 });
84
85 test('should handle empty comments response', () async {
86 final mockResponse = CommentsResponse(post: {}, comments: []);
87
88 when(
89 mockApiService.getComments(
90 postUri: anyNamed('postUri'),
91 sort: anyNamed('sort'),
92 timeframe: anyNamed('timeframe'),
93 depth: anyNamed('depth'),
94 limit: anyNamed('limit'),
95 cursor: anyNamed('cursor'),
96 ),
97 ).thenAnswer((_) async => mockResponse);
98
99 await commentsProvider.loadComments(refresh: true);
100
101 expect(commentsProvider.comments.isEmpty, true);
102 expect(commentsProvider.hasMore, false);
103 expect(commentsProvider.error, null);
104 });
105
106 test('should handle network errors', () async {
107 when(
108 mockApiService.getComments(
109 postUri: anyNamed('postUri'),
110 sort: anyNamed('sort'),
111 timeframe: anyNamed('timeframe'),
112 depth: anyNamed('depth'),
113 limit: anyNamed('limit'),
114 cursor: anyNamed('cursor'),
115 ),
116 ).thenThrow(Exception('Network error'));
117
118 await commentsProvider.loadComments(refresh: true);
119
120 expect(commentsProvider.error, isNotNull);
121 expect(commentsProvider.error, contains('Network error'));
122 expect(commentsProvider.isLoading, false);
123 expect(commentsProvider.comments.isEmpty, true);
124 });
125
126 test('should handle timeout errors', () async {
127 when(
128 mockApiService.getComments(
129 postUri: anyNamed('postUri'),
130 sort: anyNamed('sort'),
131 timeframe: anyNamed('timeframe'),
132 depth: anyNamed('depth'),
133 limit: anyNamed('limit'),
134 cursor: anyNamed('cursor'),
135 ),
136 ).thenThrow(Exception('TimeoutException: Request timed out'));
137
138 await commentsProvider.loadComments(refresh: true);
139
140 expect(commentsProvider.error, isNotNull);
141 expect(commentsProvider.isLoading, false);
142 });
143
144 test('should append comments when not refreshing', () async {
145 // First load
146 final firstResponse = CommentsResponse(
147 post: {},
148 comments: [_createMockThreadComment('comment1')],
149 cursor: 'cursor-1',
150 );
151
152 when(
153 mockApiService.getComments(
154 postUri: anyNamed('postUri'),
155 sort: anyNamed('sort'),
156 timeframe: anyNamed('timeframe'),
157 depth: anyNamed('depth'),
158 limit: anyNamed('limit'),
159 cursor: anyNamed('cursor'),
160 ),
161 ).thenAnswer((_) async => firstResponse);
162
163 await commentsProvider.loadComments(refresh: true);
164
165 expect(commentsProvider.comments.length, 1);
166
167 // Second load (pagination)
168 final secondResponse = CommentsResponse(
169 post: {},
170 comments: [_createMockThreadComment('comment2')],
171 cursor: 'cursor-2',
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: 'cursor-1',
182 ),
183 ).thenAnswer((_) async => secondResponse);
184
185 await commentsProvider.loadComments();
186
187 expect(commentsProvider.comments.length, 2);
188 expect(commentsProvider.comments[0].comment.uri, 'comment1');
189 expect(commentsProvider.comments[1].comment.uri, 'comment2');
190 });
191
192 test('should replace comments when refreshing', () async {
193 // First load
194 final firstResponse = CommentsResponse(
195 post: {},
196 comments: [_createMockThreadComment('comment1')],
197 cursor: 'cursor-1',
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: anyNamed('cursor'),
208 ),
209 ).thenAnswer((_) async => firstResponse);
210
211 await commentsProvider.loadComments(refresh: true);
212
213 expect(commentsProvider.comments.length, 1);
214
215 // Refresh with new data
216 final refreshResponse = CommentsResponse(
217 post: {},
218 comments: [
219 _createMockThreadComment('comment2'),
220 _createMockThreadComment('comment3'),
221 ],
222 cursor: 'cursor-2',
223 );
224
225 when(
226 mockApiService.getComments(
227 postUri: anyNamed('postUri'),
228 sort: anyNamed('sort'),
229 timeframe: anyNamed('timeframe'),
230 depth: anyNamed('depth'),
231 limit: anyNamed('limit'),
232 ),
233 ).thenAnswer((_) async => refreshResponse);
234
235 await commentsProvider.loadComments(refresh: true);
236
237 expect(commentsProvider.comments.length, 2);
238 expect(commentsProvider.comments[0].comment.uri, 'comment2');
239 expect(commentsProvider.comments[1].comment.uri, 'comment3');
240 });
241
242 test('should set hasMore to false when no cursor', () async {
243 final response = CommentsResponse(
244 post: {},
245 comments: [_createMockThreadComment('comment1')],
246 );
247
248 when(
249 mockApiService.getComments(
250 postUri: anyNamed('postUri'),
251 sort: anyNamed('sort'),
252 timeframe: anyNamed('timeframe'),
253 depth: anyNamed('depth'),
254 limit: anyNamed('limit'),
255 cursor: anyNamed('cursor'),
256 ),
257 ).thenAnswer((_) async => response);
258
259 await commentsProvider.loadComments(refresh: true);
260
261 expect(commentsProvider.hasMore, false);
262 });
263
264 // Note: "reset state when loading different post" test removed
265 // Providers are now immutable per post - use CommentsProviderCache
266 // to get separate providers for different posts
267
268 test('should not load when already loading', () async {
269 final response = CommentsResponse(
270 post: {},
271 comments: [_createMockThreadComment('comment1')],
272 cursor: 'cursor',
273 );
274
275 when(
276 mockApiService.getComments(
277 postUri: anyNamed('postUri'),
278 sort: anyNamed('sort'),
279 timeframe: anyNamed('timeframe'),
280 depth: anyNamed('depth'),
281 limit: anyNamed('limit'),
282 cursor: anyNamed('cursor'),
283 ),
284 ).thenAnswer((_) async {
285 await Future.delayed(const Duration(milliseconds: 100));
286 return response;
287 });
288
289 // Start first load
290 final firstFuture = commentsProvider.loadComments(refresh: true);
291
292 // Try to load again while still loading - should schedule a refresh
293 await commentsProvider.loadComments(refresh: true);
294
295 await firstFuture;
296 // Wait a bit for the pending refresh to execute
297 await Future.delayed(const Duration(milliseconds: 200));
298
299 // Should have called API twice - once for initial load, once for pending refresh
300 verify(
301 mockApiService.getComments(
302 postUri: anyNamed('postUri'),
303 sort: anyNamed('sort'),
304 timeframe: anyNamed('timeframe'),
305 depth: anyNamed('depth'),
306 limit: anyNamed('limit'),
307 cursor: anyNamed('cursor'),
308 ),
309 ).called(2);
310 });
311
312 test(
313 'should initialize vote state from viewer data when authenticated',
314 () async {
315 final mockComments = [_createMockThreadComment('comment1')];
316
317 final mockResponse = CommentsResponse(
318 post: {},
319 comments: mockComments,
320 );
321
322 when(
323 mockApiService.getComments(
324 postUri: anyNamed('postUri'),
325 sort: anyNamed('sort'),
326 timeframe: anyNamed('timeframe'),
327 depth: anyNamed('depth'),
328 limit: anyNamed('limit'),
329 cursor: anyNamed('cursor'),
330 ),
331 ).thenAnswer((_) async => mockResponse);
332
333 await commentsProvider.loadComments(refresh: true);
334
335 expect(commentsProvider.comments.length, 1);
336 expect(commentsProvider.error, null);
337 },
338 );
339
340 test('should not initialize vote state when not authenticated', () async {
341 when(mockAuthProvider.isAuthenticated).thenReturn(false);
342
343 final mockResponse = CommentsResponse(
344 post: {},
345 comments: [_createMockThreadComment('comment1')],
346 );
347
348 when(
349 mockApiService.getComments(
350 postUri: anyNamed('postUri'),
351 sort: anyNamed('sort'),
352 timeframe: anyNamed('timeframe'),
353 depth: anyNamed('depth'),
354 limit: anyNamed('limit'),
355 cursor: anyNamed('cursor'),
356 ),
357 ).thenAnswer((_) async => mockResponse);
358
359 await commentsProvider.loadComments(refresh: true);
360
361 expect(commentsProvider.comments.length, 1);
362 expect(commentsProvider.error, null);
363 });
364 });
365
366 group('setSortOption', () {
367 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
368
369 test('should change sort option and reload comments', () async {
370 // Initial load with default sort
371 final initialResponse = CommentsResponse(
372 post: {},
373 comments: [_createMockThreadComment('comment1')],
374 );
375
376 when(
377 mockApiService.getComments(
378 postUri: anyNamed('postUri'),
379 timeframe: anyNamed('timeframe'),
380 depth: anyNamed('depth'),
381 limit: anyNamed('limit'),
382 cursor: anyNamed('cursor'),
383 ),
384 ).thenAnswer((_) async => initialResponse);
385
386 await commentsProvider.loadComments(refresh: true);
387
388 expect(commentsProvider.sort, 'hot');
389
390 // Change sort option
391 final newSortResponse = CommentsResponse(
392 post: {},
393 comments: [
394 _createMockThreadComment('comment2'),
395 _createMockThreadComment('comment3'),
396 ],
397 );
398
399 when(
400 mockApiService.getComments(
401 postUri: anyNamed('postUri'),
402 sort: 'new',
403 timeframe: anyNamed('timeframe'),
404 depth: anyNamed('depth'),
405 limit: anyNamed('limit'),
406 ),
407 ).thenAnswer((_) async => newSortResponse);
408
409 await commentsProvider.setSortOption('new');
410
411 expect(commentsProvider.sort, 'new');
412 verify(
413 mockApiService.getComments(
414 postUri: testPostUri,
415 sort: 'new',
416 timeframe: anyNamed('timeframe'),
417 depth: anyNamed('depth'),
418 limit: anyNamed('limit'),
419 ),
420 ).called(1);
421 });
422
423 test('should not reload if sort option is same', () async {
424 final response = CommentsResponse(
425 post: {},
426 comments: [_createMockThreadComment('comment1')],
427 );
428
429 when(
430 mockApiService.getComments(
431 postUri: anyNamed('postUri'),
432 sort: anyNamed('sort'),
433 timeframe: anyNamed('timeframe'),
434 depth: anyNamed('depth'),
435 limit: anyNamed('limit'),
436 cursor: anyNamed('cursor'),
437 ),
438 ).thenAnswer((_) async => response);
439
440 await commentsProvider.loadComments(refresh: true);
441
442 // Try to set same sort option
443 await commentsProvider.setSortOption('hot');
444
445 // Should only have been called once (initial load)
446 verify(
447 mockApiService.getComments(
448 postUri: anyNamed('postUri'),
449 timeframe: anyNamed('timeframe'),
450 depth: anyNamed('depth'),
451 limit: anyNamed('limit'),
452 cursor: anyNamed('cursor'),
453 ),
454 ).called(1);
455 });
456 });
457
458 group('refreshComments', () {
459 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
460
461 test('should refresh comments for current post', () async {
462 final initialResponse = CommentsResponse(
463 post: {},
464 comments: [_createMockThreadComment('comment1')],
465 cursor: 'cursor-1',
466 );
467
468 when(
469 mockApiService.getComments(
470 postUri: anyNamed('postUri'),
471 sort: anyNamed('sort'),
472 timeframe: anyNamed('timeframe'),
473 depth: anyNamed('depth'),
474 limit: anyNamed('limit'),
475 cursor: anyNamed('cursor'),
476 ),
477 ).thenAnswer((_) async => initialResponse);
478
479 await commentsProvider.loadComments(refresh: true);
480
481 expect(commentsProvider.comments.length, 1);
482
483 // Refresh
484 final refreshResponse = CommentsResponse(
485 post: {},
486 comments: [
487 _createMockThreadComment('comment2'),
488 _createMockThreadComment('comment3'),
489 ],
490 );
491
492 when(
493 mockApiService.getComments(
494 postUri: testPostUri,
495 sort: anyNamed('sort'),
496 timeframe: anyNamed('timeframe'),
497 depth: anyNamed('depth'),
498 limit: anyNamed('limit'),
499 ),
500 ).thenAnswer((_) async => refreshResponse);
501
502 await commentsProvider.refreshComments();
503
504 expect(commentsProvider.comments.length, 2);
505 });
506
507 // Note: "should not refresh if no post loaded" test removed
508 // Providers now always have a post URI at construction time
509 });
510
511 group('loadMoreComments', () {
512 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
513
514 test('should load more comments when hasMore is true', () async {
515 // Initial load
516 final initialResponse = CommentsResponse(
517 post: {},
518 comments: [_createMockThreadComment('comment1')],
519 cursor: 'cursor-1',
520 );
521
522 when(
523 mockApiService.getComments(
524 postUri: anyNamed('postUri'),
525 sort: anyNamed('sort'),
526 timeframe: anyNamed('timeframe'),
527 depth: anyNamed('depth'),
528 limit: anyNamed('limit'),
529 cursor: anyNamed('cursor'),
530 ),
531 ).thenAnswer((_) async => initialResponse);
532
533 await commentsProvider.loadComments(refresh: true);
534
535 expect(commentsProvider.hasMore, true);
536
537 // Load more
538 final moreResponse = CommentsResponse(
539 post: {},
540 comments: [_createMockThreadComment('comment2')],
541 );
542
543 when(
544 mockApiService.getComments(
545 postUri: testPostUri,
546 sort: anyNamed('sort'),
547 timeframe: anyNamed('timeframe'),
548 depth: anyNamed('depth'),
549 limit: anyNamed('limit'),
550 cursor: 'cursor-1',
551 ),
552 ).thenAnswer((_) async => moreResponse);
553
554 await commentsProvider.loadMoreComments();
555
556 expect(commentsProvider.comments.length, 2);
557 expect(commentsProvider.hasMore, false);
558 });
559
560 test('should not load more when hasMore is false', () async {
561 final response = CommentsResponse(
562 post: {},
563 comments: [_createMockThreadComment('comment1')],
564 );
565
566 when(
567 mockApiService.getComments(
568 postUri: anyNamed('postUri'),
569 sort: anyNamed('sort'),
570 timeframe: anyNamed('timeframe'),
571 depth: anyNamed('depth'),
572 limit: anyNamed('limit'),
573 cursor: anyNamed('cursor'),
574 ),
575 ).thenAnswer((_) async => response);
576
577 await commentsProvider.loadComments(refresh: true);
578
579 expect(commentsProvider.hasMore, false);
580
581 // Try to load more
582 await commentsProvider.loadMoreComments();
583
584 // Should only have been called once (initial load)
585 verify(
586 mockApiService.getComments(
587 postUri: anyNamed('postUri'),
588 sort: anyNamed('sort'),
589 timeframe: anyNamed('timeframe'),
590 depth: anyNamed('depth'),
591 limit: anyNamed('limit'),
592 cursor: anyNamed('cursor'),
593 ),
594 ).called(1);
595 });
596
597 // Note: "should not load more if no post loaded" test removed
598 // Providers now always have a post URI at construction time
599 });
600
601 group('retry', () {
602 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
603
604 test('should retry after error', () async {
605 // Simulate error
606 when(
607 mockApiService.getComments(
608 postUri: anyNamed('postUri'),
609 sort: anyNamed('sort'),
610 timeframe: anyNamed('timeframe'),
611 depth: anyNamed('depth'),
612 limit: anyNamed('limit'),
613 cursor: anyNamed('cursor'),
614 ),
615 ).thenThrow(Exception('Network error'));
616
617 await commentsProvider.loadComments(refresh: true);
618
619 expect(commentsProvider.error, isNotNull);
620
621 // Retry with success
622 final successResponse = CommentsResponse(
623 post: {},
624 comments: [_createMockThreadComment('comment1')],
625 );
626
627 when(
628 mockApiService.getComments(
629 postUri: anyNamed('postUri'),
630 sort: anyNamed('sort'),
631 timeframe: anyNamed('timeframe'),
632 depth: anyNamed('depth'),
633 limit: anyNamed('limit'),
634 cursor: anyNamed('cursor'),
635 ),
636 ).thenAnswer((_) async => successResponse);
637
638 await commentsProvider.retry();
639
640 expect(commentsProvider.error, null);
641 expect(commentsProvider.comments.length, 1);
642 });
643 });
644
645 // Note: "Auth state changes" group removed
646 // Sign-out cleanup is now handled by CommentsProviderCache which disposes
647 // all cached providers when the user signs out. Individual providers no
648 // longer have a reset() method.
649
650 group('Time updates', () {
651 test('should start time updates when comments are loaded', () async {
652 final response = CommentsResponse(
653 post: {},
654 comments: [_createMockThreadComment('comment1')],
655 );
656
657 when(
658 mockApiService.getComments(
659 postUri: anyNamed('postUri'),
660 sort: anyNamed('sort'),
661 timeframe: anyNamed('timeframe'),
662 depth: anyNamed('depth'),
663 limit: anyNamed('limit'),
664 cursor: anyNamed('cursor'),
665 ),
666 ).thenAnswer((_) async => response);
667
668 expect(commentsProvider.currentTimeNotifier.value, null);
669
670 await commentsProvider.loadComments(refresh: true);
671
672 expect(commentsProvider.currentTimeNotifier.value, isNotNull);
673 });
674
675 test('should stop time updates on dispose', () async {
676 final response = CommentsResponse(
677 post: {},
678 comments: [_createMockThreadComment('comment1')],
679 );
680
681 when(
682 mockApiService.getComments(
683 postUri: anyNamed('postUri'),
684 sort: anyNamed('sort'),
685 timeframe: anyNamed('timeframe'),
686 depth: anyNamed('depth'),
687 limit: anyNamed('limit'),
688 cursor: anyNamed('cursor'),
689 ),
690 ).thenAnswer((_) async => response);
691
692 await commentsProvider.loadComments(refresh: true);
693
694 expect(commentsProvider.currentTimeNotifier.value, isNotNull);
695
696 // Call stopTimeUpdates to stop the timer
697 commentsProvider.stopTimeUpdates();
698
699 // After stopping time updates, value should be null
700 expect(commentsProvider.currentTimeNotifier.value, null);
701 });
702 });
703
704 group('State management', () {
705 test('should notify listeners on state change', () async {
706 var notificationCount = 0;
707 commentsProvider.addListener(() {
708 notificationCount++;
709 });
710
711 final response = CommentsResponse(
712 post: {},
713 comments: [_createMockThreadComment('comment1')],
714 );
715
716 when(
717 mockApiService.getComments(
718 postUri: anyNamed('postUri'),
719 sort: anyNamed('sort'),
720 timeframe: anyNamed('timeframe'),
721 depth: anyNamed('depth'),
722 limit: anyNamed('limit'),
723 cursor: anyNamed('cursor'),
724 ),
725 ).thenAnswer((_) async => response);
726
727 await commentsProvider.loadComments(refresh: true);
728
729 expect(notificationCount, greaterThan(0));
730 });
731
732 test('should manage loading states correctly', () async {
733 final response = CommentsResponse(
734 post: {},
735 comments: [_createMockThreadComment('comment1')],
736 );
737
738 when(
739 mockApiService.getComments(
740 postUri: anyNamed('postUri'),
741 sort: anyNamed('sort'),
742 timeframe: anyNamed('timeframe'),
743 depth: anyNamed('depth'),
744 limit: anyNamed('limit'),
745 cursor: anyNamed('cursor'),
746 ),
747 ).thenAnswer((_) async {
748 await Future.delayed(const Duration(milliseconds: 100));
749 return response;
750 });
751
752 final loadFuture = commentsProvider.loadComments(refresh: true);
753
754 // Should be loading
755 expect(commentsProvider.isLoading, true);
756
757 await loadFuture;
758
759 // Should not be loading anymore
760 expect(commentsProvider.isLoading, false);
761 });
762 });
763
764 group('Vote state initialization from viewer data', () {
765 const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
766
767 test('should initialize vote state when viewer.vote is "up"', () async {
768 final response = CommentsResponse(
769 post: {},
770 comments: [
771 _createMockThreadCommentWithViewer(
772 uri: 'comment1',
773 vote: 'up',
774 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
775 ),
776 ],
777 );
778
779 when(
780 mockApiService.getComments(
781 postUri: anyNamed('postUri'),
782 sort: anyNamed('sort'),
783 timeframe: anyNamed('timeframe'),
784 depth: anyNamed('depth'),
785 limit: anyNamed('limit'),
786 cursor: anyNamed('cursor'),
787 ),
788 ).thenAnswer((_) async => response);
789
790 await commentsProvider.loadComments(refresh: true);
791
792 verify(
793 mockVoteProvider.setInitialVoteState(
794 postUri: 'comment1',
795 voteDirection: 'up',
796 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
797 ),
798 ).called(1);
799 });
800
801 test('should initialize vote state when viewer.vote is "down"', () async {
802 final response = CommentsResponse(
803 post: {},
804 comments: [
805 _createMockThreadCommentWithViewer(
806 uri: 'comment1',
807 vote: 'down',
808 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
809 ),
810 ],
811 );
812
813 when(
814 mockApiService.getComments(
815 postUri: anyNamed('postUri'),
816 sort: anyNamed('sort'),
817 timeframe: anyNamed('timeframe'),
818 depth: anyNamed('depth'),
819 limit: anyNamed('limit'),
820 cursor: anyNamed('cursor'),
821 ),
822 ).thenAnswer((_) async => response);
823
824 await commentsProvider.loadComments(refresh: true);
825
826 verify(
827 mockVoteProvider.setInitialVoteState(
828 postUri: 'comment1',
829 voteDirection: 'down',
830 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
831 ),
832 ).called(1);
833 });
834
835 test(
836 'should clear stale vote state when viewer.vote is null on refresh',
837 () async {
838 final response = CommentsResponse(
839 post: {},
840 comments: [
841 _createMockThreadCommentWithViewer(
842 uri: 'comment1',
843 vote: null,
844 voteUri: null,
845 ),
846 ],
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 => response);
859
860 await commentsProvider.loadComments(refresh: true);
861
862 // Should call setInitialVoteState with null to clear stale state
863 verify(
864 mockVoteProvider.setInitialVoteState(
865 postUri: 'comment1',
866 voteDirection: null,
867 voteUri: null,
868 ),
869 ).called(1);
870 },
871 );
872
873 test(
874 'should initialize vote state recursively for nested replies',
875 () async {
876 final response = CommentsResponse(
877 post: {},
878 comments: [
879 _createMockThreadCommentWithViewer(
880 uri: 'parent-comment',
881 vote: 'up',
882 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote-parent',
883 replies: [
884 _createMockThreadCommentWithViewer(
885 uri: 'reply-comment',
886 vote: 'down',
887 voteUri:
888 'at://did:plc:test/social.coves.feed.vote/vote-reply',
889 ),
890 ],
891 ),
892 ],
893 );
894
895 when(
896 mockApiService.getComments(
897 postUri: anyNamed('postUri'),
898 sort: anyNamed('sort'),
899 timeframe: anyNamed('timeframe'),
900 depth: anyNamed('depth'),
901 limit: anyNamed('limit'),
902 cursor: anyNamed('cursor'),
903 ),
904 ).thenAnswer((_) async => response);
905
906 await commentsProvider.loadComments(refresh: true);
907
908 // Should initialize vote state for both parent and reply
909 verify(
910 mockVoteProvider.setInitialVoteState(
911 postUri: 'parent-comment',
912 voteDirection: 'up',
913 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote-parent',
914 ),
915 ).called(1);
916
917 verify(
918 mockVoteProvider.setInitialVoteState(
919 postUri: 'reply-comment',
920 voteDirection: 'down',
921 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote-reply',
922 ),
923 ).called(1);
924 },
925 );
926
927 test('should initialize vote state for deeply nested replies', () async {
928 final response = CommentsResponse(
929 post: {},
930 comments: [
931 _createMockThreadCommentWithViewer(
932 uri: 'level-0',
933 vote: 'up',
934 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote-0',
935 replies: [
936 _createMockThreadCommentWithViewer(
937 uri: 'level-1',
938 vote: 'up',
939 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote-1',
940 replies: [
941 _createMockThreadCommentWithViewer(
942 uri: 'level-2',
943 vote: 'down',
944 voteUri:
945 'at://did:plc:test/social.coves.feed.vote/vote-2',
946 ),
947 ],
948 ),
949 ],
950 ),
951 ],
952 );
953
954 when(
955 mockApiService.getComments(
956 postUri: anyNamed('postUri'),
957 sort: anyNamed('sort'),
958 timeframe: anyNamed('timeframe'),
959 depth: anyNamed('depth'),
960 limit: anyNamed('limit'),
961 cursor: anyNamed('cursor'),
962 ),
963 ).thenAnswer((_) async => response);
964
965 await commentsProvider.loadComments(refresh: true);
966
967 // Should initialize vote state for all 3 levels
968 verify(
969 mockVoteProvider.setInitialVoteState(
970 postUri: anyNamed('postUri'),
971 voteDirection: anyNamed('voteDirection'),
972 voteUri: anyNamed('voteUri'),
973 ),
974 ).called(3);
975 });
976
977 test(
978 'should only initialize vote state for new comments on pagination',
979 () async {
980 // First page: comment1 with upvote
981 final page1Response = CommentsResponse(
982 post: {},
983 comments: [
984 _createMockThreadCommentWithViewer(
985 uri: 'comment1',
986 vote: 'up',
987 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
988 ),
989 ],
990 cursor: 'cursor1',
991 );
992
993 // Second page: comment2 with downvote
994 final page2Response = CommentsResponse(
995 post: {},
996 comments: [
997 _createMockThreadCommentWithViewer(
998 uri: 'comment2',
999 vote: 'down',
1000 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote2',
1001 ),
1002 ],
1003 );
1004
1005 // First call returns page 1
1006 when(
1007 mockApiService.getComments(
1008 postUri: anyNamed('postUri'),
1009 sort: anyNamed('sort'),
1010 timeframe: anyNamed('timeframe'),
1011 depth: anyNamed('depth'),
1012 limit: anyNamed('limit'),
1013 cursor: null,
1014 ),
1015 ).thenAnswer((_) async => page1Response);
1016
1017 // Second call (with cursor) returns page 2
1018 when(
1019 mockApiService.getComments(
1020 postUri: anyNamed('postUri'),
1021 sort: anyNamed('sort'),
1022 timeframe: anyNamed('timeframe'),
1023 depth: anyNamed('depth'),
1024 limit: anyNamed('limit'),
1025 cursor: 'cursor1',
1026 ),
1027 ).thenAnswer((_) async => page2Response);
1028
1029 // Load first page (refresh)
1030 await commentsProvider.loadComments(refresh: true);
1031
1032 // Verify comment1 vote initialized
1033 verify(
1034 mockVoteProvider.setInitialVoteState(
1035 postUri: 'comment1',
1036 voteDirection: 'up',
1037 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
1038 ),
1039 ).called(1);
1040
1041 // Clear previous verifications
1042 clearInteractions(mockVoteProvider);
1043
1044 // Load second page (pagination, not refresh)
1045 await commentsProvider.loadMoreComments();
1046
1047 // Should ONLY initialize vote state for comment2 (new comments)
1048 // NOT re-initialize comment1 (which would wipe optimistic votes)
1049 verify(
1050 mockVoteProvider.setInitialVoteState(
1051 postUri: 'comment2',
1052 voteDirection: 'down',
1053 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote2',
1054 ),
1055 ).called(1);
1056
1057 // Verify comment1 was NOT re-initialized during pagination
1058 verifyNever(
1059 mockVoteProvider.setInitialVoteState(
1060 postUri: 'comment1',
1061 voteDirection: anyNamed('voteDirection'),
1062 voteUri: anyNamed('voteUri'),
1063 ),
1064 );
1065 },
1066 );
1067 });
1068
1069 group('Collapsed comments', () {
1070 test('should toggle collapsed state for a comment', () {
1071 const commentUri = 'at://did:plc:test/comment/123';
1072
1073 // Initially not collapsed
1074 expect(commentsProvider.isCollapsed(commentUri), false);
1075 expect(commentsProvider.collapsedComments.isEmpty, true);
1076
1077 // Toggle to collapsed
1078 commentsProvider.toggleCollapsed(commentUri);
1079
1080 expect(commentsProvider.isCollapsed(commentUri), true);
1081 expect(commentsProvider.collapsedComments.contains(commentUri), true);
1082
1083 // Toggle back to expanded
1084 commentsProvider.toggleCollapsed(commentUri);
1085
1086 expect(commentsProvider.isCollapsed(commentUri), false);
1087 expect(commentsProvider.collapsedComments.contains(commentUri), false);
1088 });
1089
1090 test('should track multiple collapsed comments', () {
1091 const comment1 = 'at://did:plc:test/comment/1';
1092 const comment2 = 'at://did:plc:test/comment/2';
1093 const comment3 = 'at://did:plc:test/comment/3';
1094
1095 commentsProvider
1096 ..toggleCollapsed(comment1)
1097 ..toggleCollapsed(comment2);
1098
1099 expect(commentsProvider.isCollapsed(comment1), true);
1100 expect(commentsProvider.isCollapsed(comment2), true);
1101 expect(commentsProvider.isCollapsed(comment3), false);
1102 expect(commentsProvider.collapsedComments.length, 2);
1103 });
1104
1105 test('should notify listeners when collapse state changes', () {
1106 var notificationCount = 0;
1107 commentsProvider.addListener(() {
1108 notificationCount++;
1109 });
1110
1111 commentsProvider.toggleCollapsed('at://did:plc:test/comment/1');
1112 expect(notificationCount, 1);
1113
1114 commentsProvider.toggleCollapsed('at://did:plc:test/comment/1');
1115 expect(notificationCount, 2);
1116 });
1117
1118 // Note: "clear collapsed state on reset" test removed
1119 // Providers no longer have a reset() method - they are disposed entirely
1120 // when evicted from cache or on sign-out
1121
1122 test('collapsedComments getter returns unmodifiable set', () {
1123 commentsProvider.toggleCollapsed('at://did:plc:test/comment/1');
1124
1125 final collapsed = commentsProvider.collapsedComments;
1126
1127 // Attempting to modify should throw
1128 expect(
1129 () => collapsed.add('at://did:plc:test/comment/2'),
1130 throwsUnsupportedError,
1131 );
1132 });
1133
1134 // Note: "clear collapsed state on post change" test removed
1135 // Providers are now immutable per post - each post gets its own provider
1136 // with its own collapsed state. Use CommentsProviderCache to get different
1137 // providers for different posts.
1138 });
1139
1140 group('createComment', () {
1141 late MockCommentService mockCommentService;
1142 late CommentsProvider providerWithCommentService;
1143
1144 setUp(() {
1145 mockCommentService = MockCommentService();
1146
1147 // Setup mock API service for loadComments
1148 final mockResponse = CommentsResponse(
1149 post: {},
1150 comments: [_createMockThreadComment('comment1')],
1151 );
1152 when(
1153 mockApiService.getComments(
1154 postUri: anyNamed('postUri'),
1155 sort: anyNamed('sort'),
1156 timeframe: anyNamed('timeframe'),
1157 depth: anyNamed('depth'),
1158 limit: anyNamed('limit'),
1159 cursor: anyNamed('cursor'),
1160 ),
1161 ).thenAnswer((_) async => mockResponse);
1162
1163 providerWithCommentService = CommentsProvider(
1164 mockAuthProvider,
1165 postUri: testPostUri,
1166 postCid: testPostCid,
1167 apiService: mockApiService,
1168 voteProvider: mockVoteProvider,
1169 commentService: mockCommentService,
1170 );
1171 });
1172
1173 tearDown(() {
1174 providerWithCommentService.dispose();
1175 });
1176
1177 test('should throw ValidationException for empty content', () async {
1178 // First load comments to set up post context
1179 await providerWithCommentService.loadComments(refresh: true);
1180
1181 expect(
1182 () => providerWithCommentService.createComment(content: ''),
1183 throwsA(
1184 isA<ValidationException>().having(
1185 (e) => e.message,
1186 'message',
1187 contains('empty'),
1188 ),
1189 ),
1190 );
1191 });
1192
1193 test(
1194 'should throw ValidationException for whitespace-only content',
1195 () async {
1196 await providerWithCommentService.loadComments(refresh: true);
1197
1198 expect(
1199 () =>
1200 providerWithCommentService.createComment(content: ' \n\t '),
1201 throwsA(isA<ValidationException>()),
1202 );
1203 },
1204 );
1205
1206 test(
1207 'should throw ValidationException for content exceeding limit',
1208 () async {
1209 await providerWithCommentService.loadComments(refresh: true);
1210
1211 // Create a string longer than 10000 characters
1212 final longContent = 'a' * 10001;
1213
1214 expect(
1215 () =>
1216 providerWithCommentService.createComment(content: longContent),
1217 throwsA(
1218 isA<ValidationException>().having(
1219 (e) => e.message,
1220 'message',
1221 contains('too long'),
1222 ),
1223 ),
1224 );
1225 },
1226 );
1227
1228 test('should count emoji correctly in character limit', () async {
1229 await providerWithCommentService.loadComments(refresh: true);
1230
1231 // Each emoji should count as 1 character, not 2-4 bytes
1232 // 9999 'a' chars + 1 emoji = 10000 chars (should pass)
1233 final contentAtLimit = '${'a' * 9999}😀';
1234
1235 when(
1236 mockCommentService.createComment(
1237 rootUri: anyNamed('rootUri'),
1238 rootCid: anyNamed('rootCid'),
1239 parentUri: anyNamed('parentUri'),
1240 parentCid: anyNamed('parentCid'),
1241 content: anyNamed('content'),
1242 ),
1243 ).thenAnswer(
1244 (_) async => const CreateCommentResponse(
1245 uri: 'at://did:plc:test/comment/abc',
1246 cid: 'cid123',
1247 ),
1248 );
1249
1250 // This should NOT throw
1251 await providerWithCommentService.createComment(content: contentAtLimit);
1252
1253 verify(
1254 mockCommentService.createComment(
1255 rootUri: testPostUri,
1256 rootCid: testPostCid,
1257 parentUri: testPostUri,
1258 parentCid: testPostCid,
1259 content: contentAtLimit,
1260 ),
1261 ).called(1);
1262 });
1263
1264 // Note: "should throw ApiException when no post loaded" test removed
1265 // Post context is now always provided via constructor - this case can't occur
1266
1267 test('should throw ApiException when no CommentService', () async {
1268 // Create provider without CommentService
1269 final providerWithoutService = CommentsProvider(
1270 mockAuthProvider,
1271 postUri: testPostUri,
1272 postCid: testPostCid,
1273 apiService: mockApiService,
1274 voteProvider: mockVoteProvider,
1275 );
1276
1277 expect(
1278 () => providerWithoutService.createComment(content: 'Test comment'),
1279 throwsA(
1280 isA<ApiException>().having(
1281 (e) => e.message,
1282 'message',
1283 contains('CommentService not available'),
1284 ),
1285 ),
1286 );
1287
1288 providerWithoutService.dispose();
1289 });
1290
1291 test('should create top-level comment (reply to post)', () async {
1292 await providerWithCommentService.loadComments(refresh: true);
1293
1294 when(
1295 mockCommentService.createComment(
1296 rootUri: anyNamed('rootUri'),
1297 rootCid: anyNamed('rootCid'),
1298 parentUri: anyNamed('parentUri'),
1299 parentCid: anyNamed('parentCid'),
1300 content: anyNamed('content'),
1301 ),
1302 ).thenAnswer(
1303 (_) async => const CreateCommentResponse(
1304 uri: 'at://did:plc:test/comment/abc',
1305 cid: 'cid123',
1306 ),
1307 );
1308
1309 await providerWithCommentService.createComment(
1310 content: 'This is a test comment',
1311 );
1312
1313 // Verify the comment service was called with correct parameters
1314 // Root and parent should both be the post for top-level comments
1315 verify(
1316 mockCommentService.createComment(
1317 rootUri: testPostUri,
1318 rootCid: testPostCid,
1319 parentUri: testPostUri,
1320 parentCid: testPostCid,
1321 content: 'This is a test comment',
1322 ),
1323 ).called(1);
1324 });
1325
1326 test('should create nested comment (reply to comment)', () async {
1327 await providerWithCommentService.loadComments(refresh: true);
1328
1329 when(
1330 mockCommentService.createComment(
1331 rootUri: anyNamed('rootUri'),
1332 rootCid: anyNamed('rootCid'),
1333 parentUri: anyNamed('parentUri'),
1334 parentCid: anyNamed('parentCid'),
1335 content: anyNamed('content'),
1336 ),
1337 ).thenAnswer(
1338 (_) async => const CreateCommentResponse(
1339 uri: 'at://did:plc:test/comment/reply1',
1340 cid: 'cidReply',
1341 ),
1342 );
1343
1344 // Create a parent comment to reply to
1345 final parentComment = _createMockThreadComment('parent-comment');
1346
1347 await providerWithCommentService.createComment(
1348 content: 'This is a nested reply',
1349 parentComment: parentComment,
1350 );
1351
1352 // Root should still be the post, but parent should be the comment
1353 verify(
1354 mockCommentService.createComment(
1355 rootUri: testPostUri,
1356 rootCid: testPostCid,
1357 parentUri: 'parent-comment',
1358 parentCid: 'cid-parent-comment',
1359 content: 'This is a nested reply',
1360 ),
1361 ).called(1);
1362 });
1363
1364 test('should trim content before sending', () async {
1365 await providerWithCommentService.loadComments(refresh: true);
1366
1367 when(
1368 mockCommentService.createComment(
1369 rootUri: anyNamed('rootUri'),
1370 rootCid: anyNamed('rootCid'),
1371 parentUri: anyNamed('parentUri'),
1372 parentCid: anyNamed('parentCid'),
1373 content: anyNamed('content'),
1374 ),
1375 ).thenAnswer(
1376 (_) async => const CreateCommentResponse(
1377 uri: 'at://did:plc:test/comment/abc',
1378 cid: 'cid123',
1379 ),
1380 );
1381
1382 await providerWithCommentService.createComment(
1383 content: ' Hello world! ',
1384 );
1385
1386 // Verify trimmed content was sent
1387 verify(
1388 mockCommentService.createComment(
1389 rootUri: anyNamed('rootUri'),
1390 rootCid: anyNamed('rootCid'),
1391 parentUri: anyNamed('parentUri'),
1392 parentCid: anyNamed('parentCid'),
1393 content: 'Hello world!',
1394 ),
1395 ).called(1);
1396 });
1397
1398 test('should refresh comments after successful creation', () async {
1399 await providerWithCommentService.loadComments(refresh: true);
1400
1401 when(
1402 mockCommentService.createComment(
1403 rootUri: anyNamed('rootUri'),
1404 rootCid: anyNamed('rootCid'),
1405 parentUri: anyNamed('parentUri'),
1406 parentCid: anyNamed('parentCid'),
1407 content: anyNamed('content'),
1408 ),
1409 ).thenAnswer(
1410 (_) async => const CreateCommentResponse(
1411 uri: 'at://did:plc:test/comment/abc',
1412 cid: 'cid123',
1413 ),
1414 );
1415
1416 await providerWithCommentService.createComment(content: 'Test comment');
1417
1418 // Should have called getComments twice - once for initial load,
1419 // once for refresh after comment creation
1420 verify(
1421 mockApiService.getComments(
1422 postUri: anyNamed('postUri'),
1423 sort: anyNamed('sort'),
1424 timeframe: anyNamed('timeframe'),
1425 depth: anyNamed('depth'),
1426 limit: anyNamed('limit'),
1427 cursor: anyNamed('cursor'),
1428 ),
1429 ).called(2);
1430 });
1431
1432 test('should rethrow exception from CommentService', () async {
1433 await providerWithCommentService.loadComments(refresh: true);
1434
1435 when(
1436 mockCommentService.createComment(
1437 rootUri: anyNamed('rootUri'),
1438 rootCid: anyNamed('rootCid'),
1439 parentUri: anyNamed('parentUri'),
1440 parentCid: anyNamed('parentCid'),
1441 content: anyNamed('content'),
1442 ),
1443 ).thenThrow(ApiException('Network error'));
1444
1445 expect(
1446 () =>
1447 providerWithCommentService.createComment(content: 'Test comment'),
1448 throwsA(
1449 isA<ApiException>().having(
1450 (e) => e.message,
1451 'message',
1452 contains('Network error'),
1453 ),
1454 ),
1455 );
1456 });
1457
1458 test('should accept content at exactly max length', () async {
1459 await providerWithCommentService.loadComments(refresh: true);
1460
1461 final contentAtLimit = 'a' * CommentsProvider.maxCommentLength;
1462
1463 when(
1464 mockCommentService.createComment(
1465 rootUri: anyNamed('rootUri'),
1466 rootCid: anyNamed('rootCid'),
1467 parentUri: anyNamed('parentUri'),
1468 parentCid: anyNamed('parentCid'),
1469 content: anyNamed('content'),
1470 ),
1471 ).thenAnswer(
1472 (_) async => const CreateCommentResponse(
1473 uri: 'at://did:plc:test/comment/abc',
1474 cid: 'cid123',
1475 ),
1476 );
1477
1478 // Should not throw
1479 await providerWithCommentService.createComment(content: contentAtLimit);
1480
1481 verify(
1482 mockCommentService.createComment(
1483 rootUri: anyNamed('rootUri'),
1484 rootCid: anyNamed('rootCid'),
1485 parentUri: anyNamed('parentUri'),
1486 parentCid: anyNamed('parentCid'),
1487 content: contentAtLimit,
1488 ),
1489 ).called(1);
1490 });
1491 });
1492 });
1493}
1494
1495// Helper function to create mock comments
1496ThreadViewComment _createMockThreadComment(String uri) {
1497 return ThreadViewComment(
1498 comment: CommentView(
1499 uri: uri,
1500 cid: 'cid-$uri',
1501 content: 'Test comment content',
1502 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
1503 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
1504 author: AuthorView(
1505 did: 'did:plc:author',
1506 handle: 'test.user',
1507 displayName: 'Test User',
1508 ),
1509 post: CommentRef(
1510 uri: 'at://did:plc:test/social.coves.post.record/123',
1511 cid: 'post-cid',
1512 ),
1513 stats: CommentStats(score: 10, upvotes: 12, downvotes: 2),
1514 ),
1515 );
1516}
1517
1518// Helper function to create mock comments with viewer state and optional replies
1519ThreadViewComment _createMockThreadCommentWithViewer({
1520 required String uri,
1521 String? vote,
1522 String? voteUri,
1523 List<ThreadViewComment>? replies,
1524}) {
1525 return ThreadViewComment(
1526 comment: CommentView(
1527 uri: uri,
1528 cid: 'cid-$uri',
1529 content: 'Test comment content',
1530 createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
1531 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
1532 author: AuthorView(
1533 did: 'did:plc:author',
1534 handle: 'test.user',
1535 displayName: 'Test User',
1536 ),
1537 post: CommentRef(
1538 uri: 'at://did:plc:test/social.coves.post.record/123',
1539 cid: 'post-cid',
1540 ),
1541 stats: CommentStats(score: 10, upvotes: 12, downvotes: 2),
1542 viewer: CommentViewerState(vote: vote, voteUri: voteUri),
1543 ),
1544 replies: replies,
1545 );
1546}