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:coves_flutter/services/vote_service.dart'; 8import 'package:flutter_test/flutter_test.dart'; 9import 'package:mockito/annotations.dart'; 10import 'package:mockito/mockito.dart'; 11 12import 'comments_provider_test.mocks.dart'; 13 14// Generate mocks for dependencies 15@GenerateMocks([AuthProvider, CovesApiService, VoteProvider, VoteService]) 16void main() { 17 TestWidgetsFlutterBinding.ensureInitialized(); 18 19 group('CommentsProvider', () { 20 late CommentsProvider commentsProvider; 21 late MockAuthProvider mockAuthProvider; 22 late MockCovesApiService mockApiService; 23 late MockVoteProvider mockVoteProvider; 24 late MockVoteService mockVoteService; 25 26 setUp(() { 27 mockAuthProvider = MockAuthProvider(); 28 mockApiService = MockCovesApiService(); 29 mockVoteProvider = MockVoteProvider(); 30 mockVoteService = MockVoteService(); 31 32 // Default: user is authenticated 33 when(mockAuthProvider.isAuthenticated).thenReturn(true); 34 when( 35 mockAuthProvider.getAccessToken(), 36 ).thenAnswer((_) async => 'test-token'); 37 38 commentsProvider = CommentsProvider( 39 mockAuthProvider, 40 apiService: mockApiService, 41 voteProvider: mockVoteProvider, 42 voteService: mockVoteService, 43 ); 44 }); 45 46 tearDown(() { 47 commentsProvider.dispose(); 48 }); 49 50 group('loadComments', () { 51 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 52 53 test('should load comments successfully', () async { 54 final mockComments = [ 55 _createMockThreadComment('comment1'), 56 _createMockThreadComment('comment2'), 57 ]; 58 59 final mockResponse = CommentsResponse( 60 post: {}, 61 comments: mockComments, 62 cursor: 'next-cursor', 63 ); 64 65 when( 66 mockApiService.getComments( 67 postUri: anyNamed('postUri'), 68 sort: anyNamed('sort'), 69 timeframe: anyNamed('timeframe'), 70 depth: anyNamed('depth'), 71 limit: anyNamed('limit'), 72 cursor: anyNamed('cursor'), 73 ), 74 ).thenAnswer((_) async => mockResponse); 75 76 when( 77 mockVoteService.getUserVotes(), 78 ).thenAnswer((_) async => <String, VoteInfo>{}); 79 80 await commentsProvider.loadComments( 81 postUri: testPostUri, 82 refresh: true, 83 ); 84 85 expect(commentsProvider.comments.length, 2); 86 expect(commentsProvider.hasMore, true); 87 expect(commentsProvider.error, null); 88 expect(commentsProvider.isLoading, false); 89 }); 90 91 test('should handle empty comments response', () async { 92 final mockResponse = CommentsResponse( 93 post: {}, 94 comments: [], 95 ); 96 97 when( 98 mockApiService.getComments( 99 postUri: anyNamed('postUri'), 100 sort: anyNamed('sort'), 101 timeframe: anyNamed('timeframe'), 102 depth: anyNamed('depth'), 103 limit: anyNamed('limit'), 104 cursor: anyNamed('cursor'), 105 ), 106 ).thenAnswer((_) async => mockResponse); 107 108 when( 109 mockVoteService.getUserVotes(), 110 ).thenAnswer((_) async => <String, VoteInfo>{}); 111 112 await commentsProvider.loadComments( 113 postUri: testPostUri, 114 refresh: true, 115 ); 116 117 expect(commentsProvider.comments.isEmpty, true); 118 expect(commentsProvider.hasMore, false); 119 expect(commentsProvider.error, null); 120 }); 121 122 test('should handle network errors', () async { 123 when( 124 mockApiService.getComments( 125 postUri: anyNamed('postUri'), 126 sort: anyNamed('sort'), 127 timeframe: anyNamed('timeframe'), 128 depth: anyNamed('depth'), 129 limit: anyNamed('limit'), 130 cursor: anyNamed('cursor'), 131 ), 132 ).thenThrow(Exception('Network error')); 133 134 await commentsProvider.loadComments( 135 postUri: testPostUri, 136 refresh: true, 137 ); 138 139 expect(commentsProvider.error, isNotNull); 140 expect(commentsProvider.error, contains('Network error')); 141 expect(commentsProvider.isLoading, false); 142 expect(commentsProvider.comments.isEmpty, true); 143 }); 144 145 test('should handle timeout errors', () async { 146 when( 147 mockApiService.getComments( 148 postUri: anyNamed('postUri'), 149 sort: anyNamed('sort'), 150 timeframe: anyNamed('timeframe'), 151 depth: anyNamed('depth'), 152 limit: anyNamed('limit'), 153 cursor: anyNamed('cursor'), 154 ), 155 ).thenThrow(Exception('TimeoutException: Request timed out')); 156 157 await commentsProvider.loadComments( 158 postUri: testPostUri, 159 refresh: true, 160 ); 161 162 expect(commentsProvider.error, isNotNull); 163 expect(commentsProvider.isLoading, false); 164 }); 165 166 test('should append comments when not refreshing', () async { 167 // First load 168 final firstResponse = CommentsResponse( 169 post: {}, 170 comments: [_createMockThreadComment('comment1')], 171 cursor: 'cursor-1', 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: anyNamed('cursor'), 182 ), 183 ).thenAnswer((_) async => firstResponse); 184 185 when( 186 mockVoteService.getUserVotes(), 187 ).thenAnswer((_) async => <String, VoteInfo>{}); 188 189 await commentsProvider.loadComments( 190 postUri: testPostUri, 191 refresh: true, 192 ); 193 194 expect(commentsProvider.comments.length, 1); 195 196 // Second load (pagination) 197 final secondResponse = CommentsResponse( 198 post: {}, 199 comments: [_createMockThreadComment('comment2')], 200 cursor: 'cursor-2', 201 ); 202 203 when( 204 mockApiService.getComments( 205 postUri: anyNamed('postUri'), 206 sort: anyNamed('sort'), 207 timeframe: anyNamed('timeframe'), 208 depth: anyNamed('depth'), 209 limit: anyNamed('limit'), 210 cursor: 'cursor-1', 211 ), 212 ).thenAnswer((_) async => secondResponse); 213 214 await commentsProvider.loadComments( 215 postUri: testPostUri, 216 ); 217 218 expect(commentsProvider.comments.length, 2); 219 expect(commentsProvider.comments[0].comment.uri, 'comment1'); 220 expect(commentsProvider.comments[1].comment.uri, 'comment2'); 221 }); 222 223 test('should replace comments when refreshing', () async { 224 // First load 225 final firstResponse = CommentsResponse( 226 post: {}, 227 comments: [_createMockThreadComment('comment1')], 228 cursor: 'cursor-1', 229 ); 230 231 when( 232 mockApiService.getComments( 233 postUri: anyNamed('postUri'), 234 sort: anyNamed('sort'), 235 timeframe: anyNamed('timeframe'), 236 depth: anyNamed('depth'), 237 limit: anyNamed('limit'), 238 cursor: anyNamed('cursor'), 239 ), 240 ).thenAnswer((_) async => firstResponse); 241 242 when( 243 mockVoteService.getUserVotes(), 244 ).thenAnswer((_) async => <String, VoteInfo>{}); 245 246 await commentsProvider.loadComments( 247 postUri: testPostUri, 248 refresh: true, 249 ); 250 251 expect(commentsProvider.comments.length, 1); 252 253 // Refresh with new data 254 final refreshResponse = CommentsResponse( 255 post: {}, 256 comments: [ 257 _createMockThreadComment('comment2'), 258 _createMockThreadComment('comment3'), 259 ], 260 cursor: 'cursor-2', 261 ); 262 263 when( 264 mockApiService.getComments( 265 postUri: anyNamed('postUri'), 266 sort: anyNamed('sort'), 267 timeframe: anyNamed('timeframe'), 268 depth: anyNamed('depth'), 269 limit: anyNamed('limit'), 270 ), 271 ).thenAnswer((_) async => refreshResponse); 272 273 await commentsProvider.loadComments( 274 postUri: testPostUri, 275 refresh: true, 276 ); 277 278 expect(commentsProvider.comments.length, 2); 279 expect(commentsProvider.comments[0].comment.uri, 'comment2'); 280 expect(commentsProvider.comments[1].comment.uri, 'comment3'); 281 }); 282 283 test('should set hasMore to false when no cursor', () async { 284 final response = CommentsResponse( 285 post: {}, 286 comments: [_createMockThreadComment('comment1')], 287 ); 288 289 when( 290 mockApiService.getComments( 291 postUri: anyNamed('postUri'), 292 sort: anyNamed('sort'), 293 timeframe: anyNamed('timeframe'), 294 depth: anyNamed('depth'), 295 limit: anyNamed('limit'), 296 cursor: anyNamed('cursor'), 297 ), 298 ).thenAnswer((_) async => response); 299 300 when( 301 mockVoteService.getUserVotes(), 302 ).thenAnswer((_) async => <String, VoteInfo>{}); 303 304 await commentsProvider.loadComments( 305 postUri: testPostUri, 306 refresh: true, 307 ); 308 309 expect(commentsProvider.hasMore, false); 310 }); 311 312 test('should reset state when loading different post', () async { 313 // Load first post 314 final firstResponse = CommentsResponse( 315 post: {}, 316 comments: [_createMockThreadComment('comment1')], 317 cursor: 'cursor-1', 318 ); 319 320 when( 321 mockApiService.getComments( 322 postUri: anyNamed('postUri'), 323 sort: anyNamed('sort'), 324 timeframe: anyNamed('timeframe'), 325 depth: anyNamed('depth'), 326 limit: anyNamed('limit'), 327 cursor: anyNamed('cursor'), 328 ), 329 ).thenAnswer((_) async => firstResponse); 330 331 when( 332 mockVoteService.getUserVotes(), 333 ).thenAnswer((_) async => <String, VoteInfo>{}); 334 335 await commentsProvider.loadComments( 336 postUri: testPostUri, 337 refresh: true, 338 ); 339 340 expect(commentsProvider.comments.length, 1); 341 342 // Load different post 343 const differentPostUri = 344 'at://did:plc:test/social.coves.post.record/456'; 345 final secondResponse = CommentsResponse( 346 post: {}, 347 comments: [_createMockThreadComment('comment2')], 348 ); 349 350 when( 351 mockApiService.getComments( 352 postUri: differentPostUri, 353 sort: anyNamed('sort'), 354 timeframe: anyNamed('timeframe'), 355 depth: anyNamed('depth'), 356 limit: anyNamed('limit'), 357 cursor: anyNamed('cursor'), 358 ), 359 ).thenAnswer((_) async => secondResponse); 360 361 await commentsProvider.loadComments( 362 postUri: differentPostUri, 363 refresh: true, 364 ); 365 366 // Should have reset and loaded new comments 367 expect(commentsProvider.comments.length, 1); 368 expect(commentsProvider.comments[0].comment.uri, 'comment2'); 369 }); 370 371 test('should not load when already loading', () async { 372 final response = CommentsResponse( 373 post: {}, 374 comments: [_createMockThreadComment('comment1')], 375 cursor: 'cursor', 376 ); 377 378 when( 379 mockApiService.getComments( 380 postUri: anyNamed('postUri'), 381 sort: anyNamed('sort'), 382 timeframe: anyNamed('timeframe'), 383 depth: anyNamed('depth'), 384 limit: anyNamed('limit'), 385 cursor: anyNamed('cursor'), 386 ), 387 ).thenAnswer((_) async { 388 await Future.delayed(const Duration(milliseconds: 100)); 389 return response; 390 }); 391 392 when( 393 mockVoteService.getUserVotes(), 394 ).thenAnswer((_) async => <String, VoteInfo>{}); 395 396 // Start first load 397 final firstFuture = commentsProvider.loadComments( 398 postUri: testPostUri, 399 refresh: true, 400 ); 401 402 // Try to load again while still loading 403 await commentsProvider.loadComments( 404 postUri: testPostUri, 405 refresh: true, 406 ); 407 408 await firstFuture; 409 410 // Should only have called API once 411 verify( 412 mockApiService.getComments( 413 postUri: anyNamed('postUri'), 414 sort: anyNamed('sort'), 415 timeframe: anyNamed('timeframe'), 416 depth: anyNamed('depth'), 417 limit: anyNamed('limit'), 418 cursor: anyNamed('cursor'), 419 ), 420 ).called(1); 421 }); 422 423 test('should load vote state when authenticated', () async { 424 final mockComments = [_createMockThreadComment('comment1')]; 425 426 final mockResponse = CommentsResponse( 427 post: {}, 428 comments: mockComments, 429 ); 430 431 when( 432 mockApiService.getComments( 433 postUri: anyNamed('postUri'), 434 sort: anyNamed('sort'), 435 timeframe: anyNamed('timeframe'), 436 depth: anyNamed('depth'), 437 limit: anyNamed('limit'), 438 cursor: anyNamed('cursor'), 439 ), 440 ).thenAnswer((_) async => mockResponse); 441 442 final mockUserVotes = <String, VoteInfo>{ 443 'comment1': const VoteInfo( 444 voteUri: 'at://did:plc:test/social.coves.feed.vote/123', 445 direction: 'up', 446 rkey: '123', 447 ), 448 }; 449 450 when( 451 mockVoteService.getUserVotes(), 452 ).thenAnswer((_) async => mockUserVotes); 453 when(mockVoteProvider.loadInitialVotes(any)).thenReturn(null); 454 455 await commentsProvider.loadComments( 456 postUri: testPostUri, 457 refresh: true, 458 ); 459 460 verify(mockVoteService.getUserVotes()).called(1); 461 verify(mockVoteProvider.loadInitialVotes(mockUserVotes)).called(1); 462 }); 463 464 test('should not load vote state when not authenticated', () async { 465 when(mockAuthProvider.isAuthenticated).thenReturn(false); 466 467 final mockResponse = CommentsResponse( 468 post: {}, 469 comments: [_createMockThreadComment('comment1')], 470 ); 471 472 when( 473 mockApiService.getComments( 474 postUri: anyNamed('postUri'), 475 sort: anyNamed('sort'), 476 timeframe: anyNamed('timeframe'), 477 depth: anyNamed('depth'), 478 limit: anyNamed('limit'), 479 cursor: anyNamed('cursor'), 480 ), 481 ).thenAnswer((_) async => mockResponse); 482 483 await commentsProvider.loadComments( 484 postUri: testPostUri, 485 refresh: true, 486 ); 487 488 verifyNever(mockVoteService.getUserVotes()); 489 verifyNever(mockVoteProvider.loadInitialVotes(any)); 490 }); 491 492 test('should continue loading comments if vote loading fails', () async { 493 final mockComments = [_createMockThreadComment('comment1')]; 494 495 final mockResponse = CommentsResponse( 496 post: {}, 497 comments: mockComments, 498 ); 499 500 when( 501 mockApiService.getComments( 502 postUri: anyNamed('postUri'), 503 sort: anyNamed('sort'), 504 timeframe: anyNamed('timeframe'), 505 depth: anyNamed('depth'), 506 limit: anyNamed('limit'), 507 cursor: anyNamed('cursor'), 508 ), 509 ).thenAnswer((_) async => mockResponse); 510 511 when( 512 mockVoteService.getUserVotes(), 513 ).thenThrow(Exception('Vote service error')); 514 515 await commentsProvider.loadComments( 516 postUri: testPostUri, 517 refresh: true, 518 ); 519 520 // Comments should still be loaded despite vote error 521 expect(commentsProvider.comments.length, 1); 522 expect(commentsProvider.error, null); 523 }); 524 }); 525 526 group('setSortOption', () { 527 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 528 529 test('should change sort option and reload comments', () async { 530 // Initial load with default sort 531 final initialResponse = CommentsResponse( 532 post: {}, 533 comments: [_createMockThreadComment('comment1')], 534 ); 535 536 when( 537 mockApiService.getComments( 538 postUri: anyNamed('postUri'), 539 timeframe: anyNamed('timeframe'), 540 depth: anyNamed('depth'), 541 limit: anyNamed('limit'), 542 cursor: anyNamed('cursor'), 543 ), 544 ).thenAnswer((_) async => initialResponse); 545 546 when( 547 mockVoteService.getUserVotes(), 548 ).thenAnswer((_) async => <String, VoteInfo>{}); 549 550 await commentsProvider.loadComments( 551 postUri: testPostUri, 552 refresh: true, 553 ); 554 555 expect(commentsProvider.sort, 'hot'); 556 557 // Change sort option 558 final newSortResponse = CommentsResponse( 559 post: {}, 560 comments: [ 561 _createMockThreadComment('comment2'), 562 _createMockThreadComment('comment3'), 563 ], 564 ); 565 566 when( 567 mockApiService.getComments( 568 postUri: anyNamed('postUri'), 569 sort: 'new', 570 timeframe: anyNamed('timeframe'), 571 depth: anyNamed('depth'), 572 limit: anyNamed('limit'), 573 ), 574 ).thenAnswer((_) async => newSortResponse); 575 576 await commentsProvider.setSortOption('new'); 577 578 expect(commentsProvider.sort, 'new'); 579 verify( 580 mockApiService.getComments( 581 postUri: testPostUri, 582 sort: 'new', 583 timeframe: anyNamed('timeframe'), 584 depth: anyNamed('depth'), 585 limit: anyNamed('limit'), 586 ), 587 ).called(1); 588 }); 589 590 test('should not reload if sort option is same', () async { 591 final response = CommentsResponse( 592 post: {}, 593 comments: [_createMockThreadComment('comment1')], 594 ); 595 596 when( 597 mockApiService.getComments( 598 postUri: anyNamed('postUri'), 599 sort: anyNamed('sort'), 600 timeframe: anyNamed('timeframe'), 601 depth: anyNamed('depth'), 602 limit: anyNamed('limit'), 603 cursor: anyNamed('cursor'), 604 ), 605 ).thenAnswer((_) async => response); 606 607 when( 608 mockVoteService.getUserVotes(), 609 ).thenAnswer((_) async => <String, VoteInfo>{}); 610 611 await commentsProvider.loadComments( 612 postUri: testPostUri, 613 refresh: true, 614 ); 615 616 // Try to set same sort option 617 await commentsProvider.setSortOption('hot'); 618 619 // Should only have been called once (initial load) 620 verify( 621 mockApiService.getComments( 622 postUri: anyNamed('postUri'), 623 timeframe: anyNamed('timeframe'), 624 depth: anyNamed('depth'), 625 limit: anyNamed('limit'), 626 cursor: anyNamed('cursor'), 627 ), 628 ).called(1); 629 }); 630 }); 631 632 group('refreshComments', () { 633 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 634 635 test('should refresh comments for current post', () async { 636 final initialResponse = CommentsResponse( 637 post: {}, 638 comments: [_createMockThreadComment('comment1')], 639 cursor: 'cursor-1', 640 ); 641 642 when( 643 mockApiService.getComments( 644 postUri: anyNamed('postUri'), 645 sort: anyNamed('sort'), 646 timeframe: anyNamed('timeframe'), 647 depth: anyNamed('depth'), 648 limit: anyNamed('limit'), 649 cursor: anyNamed('cursor'), 650 ), 651 ).thenAnswer((_) async => initialResponse); 652 653 when( 654 mockVoteService.getUserVotes(), 655 ).thenAnswer((_) async => <String, VoteInfo>{}); 656 657 await commentsProvider.loadComments( 658 postUri: testPostUri, 659 refresh: true, 660 ); 661 662 expect(commentsProvider.comments.length, 1); 663 664 // Refresh 665 final refreshResponse = CommentsResponse( 666 post: {}, 667 comments: [ 668 _createMockThreadComment('comment2'), 669 _createMockThreadComment('comment3'), 670 ], 671 ); 672 673 when( 674 mockApiService.getComments( 675 postUri: testPostUri, 676 sort: anyNamed('sort'), 677 timeframe: anyNamed('timeframe'), 678 depth: anyNamed('depth'), 679 limit: anyNamed('limit'), 680 ), 681 ).thenAnswer((_) async => refreshResponse); 682 683 await commentsProvider.refreshComments(); 684 685 expect(commentsProvider.comments.length, 2); 686 }); 687 688 test('should not refresh if no post loaded', () async { 689 await commentsProvider.refreshComments(); 690 691 verifyNever( 692 mockApiService.getComments( 693 postUri: anyNamed('postUri'), 694 sort: anyNamed('sort'), 695 timeframe: anyNamed('timeframe'), 696 depth: anyNamed('depth'), 697 limit: anyNamed('limit'), 698 cursor: anyNamed('cursor'), 699 ), 700 ); 701 }); 702 }); 703 704 group('loadMoreComments', () { 705 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 706 707 test('should load more comments when hasMore is true', () async { 708 // Initial load 709 final initialResponse = CommentsResponse( 710 post: {}, 711 comments: [_createMockThreadComment('comment1')], 712 cursor: 'cursor-1', 713 ); 714 715 when( 716 mockApiService.getComments( 717 postUri: anyNamed('postUri'), 718 sort: anyNamed('sort'), 719 timeframe: anyNamed('timeframe'), 720 depth: anyNamed('depth'), 721 limit: anyNamed('limit'), 722 cursor: anyNamed('cursor'), 723 ), 724 ).thenAnswer((_) async => initialResponse); 725 726 when( 727 mockVoteService.getUserVotes(), 728 ).thenAnswer((_) async => <String, VoteInfo>{}); 729 730 await commentsProvider.loadComments( 731 postUri: testPostUri, 732 refresh: true, 733 ); 734 735 expect(commentsProvider.hasMore, true); 736 737 // Load more 738 final moreResponse = CommentsResponse( 739 post: {}, 740 comments: [_createMockThreadComment('comment2')], 741 ); 742 743 when( 744 mockApiService.getComments( 745 postUri: testPostUri, 746 sort: anyNamed('sort'), 747 timeframe: anyNamed('timeframe'), 748 depth: anyNamed('depth'), 749 limit: anyNamed('limit'), 750 cursor: 'cursor-1', 751 ), 752 ).thenAnswer((_) async => moreResponse); 753 754 await commentsProvider.loadMoreComments(); 755 756 expect(commentsProvider.comments.length, 2); 757 expect(commentsProvider.hasMore, false); 758 }); 759 760 test('should not load more when hasMore is false', () async { 761 final response = CommentsResponse( 762 post: {}, 763 comments: [_createMockThreadComment('comment1')], 764 ); 765 766 when( 767 mockApiService.getComments( 768 postUri: anyNamed('postUri'), 769 sort: anyNamed('sort'), 770 timeframe: anyNamed('timeframe'), 771 depth: anyNamed('depth'), 772 limit: anyNamed('limit'), 773 cursor: anyNamed('cursor'), 774 ), 775 ).thenAnswer((_) async => response); 776 777 when( 778 mockVoteService.getUserVotes(), 779 ).thenAnswer((_) async => <String, VoteInfo>{}); 780 781 await commentsProvider.loadComments( 782 postUri: testPostUri, 783 refresh: true, 784 ); 785 786 expect(commentsProvider.hasMore, false); 787 788 // Try to load more 789 await commentsProvider.loadMoreComments(); 790 791 // Should only have been called once (initial load) 792 verify( 793 mockApiService.getComments( 794 postUri: anyNamed('postUri'), 795 sort: anyNamed('sort'), 796 timeframe: anyNamed('timeframe'), 797 depth: anyNamed('depth'), 798 limit: anyNamed('limit'), 799 cursor: anyNamed('cursor'), 800 ), 801 ).called(1); 802 }); 803 804 test('should not load more if no post loaded', () async { 805 await commentsProvider.loadMoreComments(); 806 807 verifyNever( 808 mockApiService.getComments( 809 postUri: anyNamed('postUri'), 810 sort: anyNamed('sort'), 811 timeframe: anyNamed('timeframe'), 812 depth: anyNamed('depth'), 813 limit: anyNamed('limit'), 814 cursor: anyNamed('cursor'), 815 ), 816 ); 817 }); 818 }); 819 820 group('retry', () { 821 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 822 823 test('should retry after error', () async { 824 // Simulate error 825 when( 826 mockApiService.getComments( 827 postUri: anyNamed('postUri'), 828 sort: anyNamed('sort'), 829 timeframe: anyNamed('timeframe'), 830 depth: anyNamed('depth'), 831 limit: anyNamed('limit'), 832 cursor: anyNamed('cursor'), 833 ), 834 ).thenThrow(Exception('Network error')); 835 836 await commentsProvider.loadComments( 837 postUri: testPostUri, 838 refresh: true, 839 ); 840 841 expect(commentsProvider.error, isNotNull); 842 843 // Retry with success 844 final successResponse = CommentsResponse( 845 post: {}, 846 comments: [_createMockThreadComment('comment1')], 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 => successResponse); 859 860 when( 861 mockVoteService.getUserVotes(), 862 ).thenAnswer((_) async => <String, VoteInfo>{}); 863 864 await commentsProvider.retry(); 865 866 expect(commentsProvider.error, null); 867 expect(commentsProvider.comments.length, 1); 868 }); 869 }); 870 871 group('Auth state changes', () { 872 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 873 874 test('should clear comments on sign-out', () async { 875 final response = CommentsResponse( 876 post: {}, 877 comments: [_createMockThreadComment('comment1')], 878 ); 879 880 when( 881 mockApiService.getComments( 882 postUri: anyNamed('postUri'), 883 sort: anyNamed('sort'), 884 timeframe: anyNamed('timeframe'), 885 depth: anyNamed('depth'), 886 limit: anyNamed('limit'), 887 cursor: anyNamed('cursor'), 888 ), 889 ).thenAnswer((_) async => response); 890 891 when( 892 mockVoteService.getUserVotes(), 893 ).thenAnswer((_) async => <String, VoteInfo>{}); 894 895 await commentsProvider.loadComments( 896 postUri: testPostUri, 897 refresh: true, 898 ); 899 900 expect(commentsProvider.comments.length, 1); 901 902 // Simulate sign-out 903 when(mockAuthProvider.isAuthenticated).thenReturn(false); 904 // Trigger listener manually since we're using a mock 905 commentsProvider.reset(); 906 907 expect(commentsProvider.comments.isEmpty, true); 908 }); 909 }); 910 911 group('Time updates', () { 912 test('should start time updates when comments are loaded', () async { 913 final response = CommentsResponse( 914 post: {}, 915 comments: [_createMockThreadComment('comment1')], 916 ); 917 918 when( 919 mockApiService.getComments( 920 postUri: anyNamed('postUri'), 921 sort: anyNamed('sort'), 922 timeframe: anyNamed('timeframe'), 923 depth: anyNamed('depth'), 924 limit: anyNamed('limit'), 925 cursor: anyNamed('cursor'), 926 ), 927 ).thenAnswer((_) async => response); 928 929 when( 930 mockVoteService.getUserVotes(), 931 ).thenAnswer((_) async => <String, VoteInfo>{}); 932 933 expect(commentsProvider.currentTimeNotifier.value, null); 934 935 await commentsProvider.loadComments( 936 postUri: 'at://did:plc:test/social.coves.post.record/123', 937 refresh: true, 938 ); 939 940 expect(commentsProvider.currentTimeNotifier.value, isNotNull); 941 }); 942 943 test('should stop time updates on dispose', () async { 944 final response = CommentsResponse( 945 post: {}, 946 comments: [_createMockThreadComment('comment1')], 947 ); 948 949 when( 950 mockApiService.getComments( 951 postUri: anyNamed('postUri'), 952 sort: anyNamed('sort'), 953 timeframe: anyNamed('timeframe'), 954 depth: anyNamed('depth'), 955 limit: anyNamed('limit'), 956 cursor: anyNamed('cursor'), 957 ), 958 ).thenAnswer((_) async => response); 959 960 when( 961 mockVoteService.getUserVotes(), 962 ).thenAnswer((_) async => <String, VoteInfo>{}); 963 964 await commentsProvider.loadComments( 965 postUri: 'at://did:plc:test/social.coves.post.record/123', 966 refresh: true, 967 ); 968 969 expect(commentsProvider.currentTimeNotifier.value, isNotNull); 970 971 // Call stopTimeUpdates to stop the timer 972 commentsProvider.stopTimeUpdates(); 973 974 // After stopping time updates, value should be null 975 expect(commentsProvider.currentTimeNotifier.value, null); 976 }); 977 }); 978 979 group('State management', () { 980 test('should notify listeners on state change', () async { 981 var notificationCount = 0; 982 commentsProvider.addListener(() { 983 notificationCount++; 984 }); 985 986 final response = CommentsResponse( 987 post: {}, 988 comments: [_createMockThreadComment('comment1')], 989 ); 990 991 when( 992 mockApiService.getComments( 993 postUri: anyNamed('postUri'), 994 sort: anyNamed('sort'), 995 timeframe: anyNamed('timeframe'), 996 depth: anyNamed('depth'), 997 limit: anyNamed('limit'), 998 cursor: anyNamed('cursor'), 999 ), 1000 ).thenAnswer((_) async => response); 1001 1002 when( 1003 mockVoteService.getUserVotes(), 1004 ).thenAnswer((_) async => <String, VoteInfo>{}); 1005 1006 await commentsProvider.loadComments( 1007 postUri: 'at://did:plc:test/social.coves.post.record/123', 1008 refresh: true, 1009 ); 1010 1011 expect(notificationCount, greaterThan(0)); 1012 }); 1013 1014 test('should manage loading states correctly', () async { 1015 final response = CommentsResponse( 1016 post: {}, 1017 comments: [_createMockThreadComment('comment1')], 1018 ); 1019 1020 when( 1021 mockApiService.getComments( 1022 postUri: anyNamed('postUri'), 1023 sort: anyNamed('sort'), 1024 timeframe: anyNamed('timeframe'), 1025 depth: anyNamed('depth'), 1026 limit: anyNamed('limit'), 1027 cursor: anyNamed('cursor'), 1028 ), 1029 ).thenAnswer((_) async { 1030 await Future.delayed(const Duration(milliseconds: 100)); 1031 return response; 1032 }); 1033 1034 when( 1035 mockVoteService.getUserVotes(), 1036 ).thenAnswer((_) async => <String, VoteInfo>{}); 1037 1038 final loadFuture = commentsProvider.loadComments( 1039 postUri: 'at://did:plc:test/social.coves.post.record/123', 1040 refresh: true, 1041 ); 1042 1043 // Should be loading 1044 expect(commentsProvider.isLoading, true); 1045 1046 await loadFuture; 1047 1048 // Should not be loading anymore 1049 expect(commentsProvider.isLoading, false); 1050 }); 1051 }); 1052 }); 1053} 1054 1055// Helper function to create mock comments 1056ThreadViewComment _createMockThreadComment(String uri) { 1057 return ThreadViewComment( 1058 comment: CommentView( 1059 uri: uri, 1060 cid: 'cid-$uri', 1061 content: 'Test comment content', 1062 createdAt: DateTime.parse('2025-01-01T12:00:00Z'), 1063 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), 1064 author: AuthorView( 1065 did: 'did:plc:author', 1066 handle: 'test.user', 1067 displayName: 'Test User', 1068 ), 1069 post: CommentRef( 1070 uri: 'at://did:plc:test/social.coves.post.record/123', 1071 cid: 'post-cid', 1072 ), 1073 stats: CommentStats(score: 10, upvotes: 12, downvotes: 2), 1074 ), 1075 ); 1076}