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