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