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