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}