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