1import 'package:coves_flutter/models/post.dart'; 2import 'package:coves_flutter/providers/auth_provider.dart'; 3import 'package:coves_flutter/providers/feed_provider.dart'; 4import 'package:coves_flutter/providers/vote_provider.dart'; 5import 'package:coves_flutter/services/coves_api_service.dart'; 6import 'package:flutter_test/flutter_test.dart'; 7import 'package:mockito/annotations.dart'; 8import 'package:mockito/mockito.dart'; 9 10import 'feed_provider_test.mocks.dart'; 11 12// Generate mocks 13@GenerateMocks([AuthProvider, CovesApiService, VoteProvider]) 14void main() { 15 group('FeedProvider', () { 16 late FeedProvider feedProvider; 17 late MockAuthProvider mockAuthProvider; 18 late MockCovesApiService mockApiService; 19 20 setUp(() { 21 mockAuthProvider = MockAuthProvider(); 22 mockApiService = MockCovesApiService(); 23 24 // Mock default auth state 25 when(mockAuthProvider.isAuthenticated).thenReturn(false); 26 27 // Mock the token getter 28 when( 29 mockAuthProvider.getAccessToken(), 30 ).thenAnswer((_) async => 'test-token'); 31 32 // Create feed provider with injected mock service 33 feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService); 34 }); 35 36 tearDown(() { 37 feedProvider.dispose(); 38 }); 39 40 group('loadFeed', () { 41 test('should load discover feed when authenticated by default', () async { 42 when(mockAuthProvider.isAuthenticated).thenReturn(true); 43 44 final mockResponse = TimelineResponse( 45 feed: [_createMockPost()], 46 cursor: 'next-cursor', 47 ); 48 49 when( 50 mockApiService.getDiscover( 51 sort: anyNamed('sort'), 52 timeframe: anyNamed('timeframe'), 53 limit: anyNamed('limit'), 54 cursor: anyNamed('cursor'), 55 ), 56 ).thenAnswer((_) async => mockResponse); 57 58 await feedProvider.loadFeed(refresh: true); 59 60 expect(feedProvider.posts.length, 1); 61 expect(feedProvider.error, null); 62 expect(feedProvider.isLoading, false); 63 }); 64 65 test('should load timeline when feed type is For You', () async { 66 when(mockAuthProvider.isAuthenticated).thenReturn(true); 67 68 final mockResponse = TimelineResponse( 69 feed: [_createMockPost()], 70 cursor: 'next-cursor', 71 ); 72 73 when( 74 mockApiService.getTimeline( 75 sort: anyNamed('sort'), 76 timeframe: anyNamed('timeframe'), 77 limit: anyNamed('limit'), 78 cursor: anyNamed('cursor'), 79 ), 80 ).thenAnswer((_) async => mockResponse); 81 82 await feedProvider.setFeedType(FeedType.forYou); 83 84 expect(feedProvider.posts.length, 1); 85 expect(feedProvider.error, null); 86 expect(feedProvider.isLoading, false); 87 }); 88 89 test('should load discover feed when not authenticated', () async { 90 when(mockAuthProvider.isAuthenticated).thenReturn(false); 91 92 final mockResponse = TimelineResponse( 93 feed: [_createMockPost()], 94 cursor: 'next-cursor', 95 ); 96 97 when( 98 mockApiService.getDiscover( 99 sort: anyNamed('sort'), 100 timeframe: anyNamed('timeframe'), 101 limit: anyNamed('limit'), 102 cursor: anyNamed('cursor'), 103 ), 104 ).thenAnswer((_) async => mockResponse); 105 106 await feedProvider.loadFeed(refresh: true); 107 108 expect(feedProvider.posts.length, 1); 109 expect(feedProvider.error, null); 110 }); 111 }); 112 113 group('fetchTimeline', () { 114 test('should fetch timeline successfully', () async { 115 final mockResponse = TimelineResponse( 116 feed: [_createMockPost(), _createMockPost()], 117 cursor: 'next-cursor', 118 ); 119 120 when( 121 mockApiService.getTimeline( 122 sort: anyNamed('sort'), 123 timeframe: anyNamed('timeframe'), 124 limit: anyNamed('limit'), 125 cursor: anyNamed('cursor'), 126 ), 127 ).thenAnswer((_) async => mockResponse); 128 129 await feedProvider.fetchTimeline(refresh: true); 130 131 expect(feedProvider.posts.length, 2); 132 expect(feedProvider.hasMore, true); 133 expect(feedProvider.error, null); 134 }); 135 136 test('should handle network errors', () async { 137 when( 138 mockApiService.getTimeline( 139 sort: anyNamed('sort'), 140 timeframe: anyNamed('timeframe'), 141 limit: anyNamed('limit'), 142 cursor: anyNamed('cursor'), 143 ), 144 ).thenThrow(Exception('Network error')); 145 146 await feedProvider.fetchTimeline(refresh: true); 147 148 expect(feedProvider.error, isNotNull); 149 expect(feedProvider.isLoading, false); 150 }); 151 152 test('should append posts when not refreshing', () async { 153 // First load 154 final firstResponse = TimelineResponse( 155 feed: [_createMockPost()], 156 cursor: 'cursor-1', 157 ); 158 159 when( 160 mockApiService.getTimeline( 161 sort: anyNamed('sort'), 162 timeframe: anyNamed('timeframe'), 163 limit: anyNamed('limit'), 164 cursor: anyNamed('cursor'), 165 ), 166 ).thenAnswer((_) async => firstResponse); 167 168 await feedProvider.fetchTimeline(refresh: true); 169 expect(feedProvider.posts.length, 1); 170 171 // Second load (pagination) 172 final secondResponse = TimelineResponse( 173 feed: [_createMockPost()], 174 cursor: 'cursor-2', 175 ); 176 177 when( 178 mockApiService.getTimeline( 179 sort: anyNamed('sort'), 180 timeframe: anyNamed('timeframe'), 181 limit: anyNamed('limit'), 182 cursor: 'cursor-1', 183 ), 184 ).thenAnswer((_) async => secondResponse); 185 186 await feedProvider.fetchTimeline(); 187 expect(feedProvider.posts.length, 2); 188 }); 189 190 test('should replace posts when refreshing', () async { 191 // First load 192 final firstResponse = TimelineResponse( 193 feed: [_createMockPost()], 194 cursor: 'cursor-1', 195 ); 196 197 when( 198 mockApiService.getTimeline( 199 sort: anyNamed('sort'), 200 timeframe: anyNamed('timeframe'), 201 limit: anyNamed('limit'), 202 cursor: anyNamed('cursor'), 203 ), 204 ).thenAnswer((_) async => firstResponse); 205 206 await feedProvider.fetchTimeline(refresh: true); 207 expect(feedProvider.posts.length, 1); 208 209 // Refresh 210 final refreshResponse = TimelineResponse( 211 feed: [_createMockPost(), _createMockPost()], 212 cursor: 'cursor-2', 213 ); 214 215 when( 216 mockApiService.getTimeline( 217 sort: anyNamed('sort'), 218 timeframe: anyNamed('timeframe'), 219 limit: anyNamed('limit'), 220 ), 221 ).thenAnswer((_) async => refreshResponse); 222 223 await feedProvider.fetchTimeline(refresh: true); 224 expect(feedProvider.posts.length, 2); 225 }); 226 227 test('should set hasMore to false when no cursor', () async { 228 final response = TimelineResponse(feed: [_createMockPost()]); 229 230 when( 231 mockApiService.getTimeline( 232 sort: anyNamed('sort'), 233 timeframe: anyNamed('timeframe'), 234 limit: anyNamed('limit'), 235 cursor: anyNamed('cursor'), 236 ), 237 ).thenAnswer((_) async => response); 238 239 await feedProvider.fetchTimeline(refresh: true); 240 241 expect(feedProvider.hasMore, false); 242 }); 243 }); 244 245 group('fetchDiscover', () { 246 test('should fetch discover feed successfully', () async { 247 final mockResponse = TimelineResponse( 248 feed: [_createMockPost()], 249 cursor: 'next-cursor', 250 ); 251 252 when( 253 mockApiService.getDiscover( 254 sort: anyNamed('sort'), 255 timeframe: anyNamed('timeframe'), 256 limit: anyNamed('limit'), 257 cursor: anyNamed('cursor'), 258 ), 259 ).thenAnswer((_) async => mockResponse); 260 261 await feedProvider.fetchDiscover(refresh: true); 262 263 expect(feedProvider.posts.length, 1); 264 expect(feedProvider.error, null); 265 }); 266 267 test('should handle empty feed', () async { 268 final emptyResponse = TimelineResponse(feed: []); 269 270 when( 271 mockApiService.getDiscover( 272 sort: anyNamed('sort'), 273 timeframe: anyNamed('timeframe'), 274 limit: anyNamed('limit'), 275 cursor: anyNamed('cursor'), 276 ), 277 ).thenAnswer((_) async => emptyResponse); 278 279 await feedProvider.fetchDiscover(refresh: true); 280 281 expect(feedProvider.posts.isEmpty, true); 282 expect(feedProvider.hasMore, false); 283 }); 284 }); 285 286 group('loadMore', () { 287 test('should load more posts', () async { 288 when(mockAuthProvider.isAuthenticated).thenReturn(true); 289 290 // Initial load 291 final firstResponse = TimelineResponse( 292 feed: [_createMockPost()], 293 cursor: 'cursor-1', 294 ); 295 296 when( 297 mockApiService.getTimeline( 298 sort: anyNamed('sort'), 299 timeframe: anyNamed('timeframe'), 300 limit: anyNamed('limit'), 301 cursor: anyNamed('cursor'), 302 ), 303 ).thenAnswer((_) async => firstResponse); 304 305 await feedProvider.setFeedType(FeedType.forYou); 306 307 // Load more 308 final secondResponse = TimelineResponse( 309 feed: [_createMockPost()], 310 cursor: 'cursor-2', 311 ); 312 313 when( 314 mockApiService.getTimeline( 315 sort: anyNamed('sort'), 316 timeframe: anyNamed('timeframe'), 317 limit: anyNamed('limit'), 318 cursor: 'cursor-1', 319 ), 320 ).thenAnswer((_) async => secondResponse); 321 322 await feedProvider.loadMore(); 323 324 expect(feedProvider.posts.length, 2); 325 }); 326 327 test('should not load more if already loading', () async { 328 when(mockAuthProvider.isAuthenticated).thenReturn(true); 329 330 final response = TimelineResponse( 331 feed: [_createMockPost()], 332 cursor: 'cursor-1', 333 ); 334 335 when( 336 mockApiService.getTimeline( 337 sort: anyNamed('sort'), 338 timeframe: anyNamed('timeframe'), 339 limit: anyNamed('limit'), 340 cursor: anyNamed('cursor'), 341 ), 342 ).thenAnswer((_) async => response); 343 344 await feedProvider.setFeedType(FeedType.forYou); 345 await feedProvider.loadMore(); 346 347 // Should not make additional calls while loading 348 }); 349 350 test('should not load more if hasMore is false', () async { 351 final response = TimelineResponse(feed: [_createMockPost()]); 352 353 when( 354 mockApiService.getTimeline( 355 sort: anyNamed('sort'), 356 timeframe: anyNamed('timeframe'), 357 limit: anyNamed('limit'), 358 cursor: anyNamed('cursor'), 359 ), 360 ).thenAnswer((_) async => response); 361 362 await feedProvider.fetchTimeline(refresh: true); 363 expect(feedProvider.hasMore, false); 364 365 await feedProvider.loadMore(); 366 // Should not attempt to load more 367 }); 368 }); 369 370 group('retry', () { 371 test('should retry after error', () async { 372 when(mockAuthProvider.isAuthenticated).thenReturn(true); 373 374 // Simulate error 375 when( 376 mockApiService.getTimeline( 377 sort: anyNamed('sort'), 378 timeframe: anyNamed('timeframe'), 379 limit: anyNamed('limit'), 380 cursor: anyNamed('cursor'), 381 ), 382 ).thenThrow(Exception('Network error')); 383 384 await feedProvider.setFeedType(FeedType.forYou); 385 expect(feedProvider.error, isNotNull); 386 387 // Retry 388 final successResponse = TimelineResponse( 389 feed: [_createMockPost()], 390 cursor: 'cursor', 391 ); 392 393 when( 394 mockApiService.getTimeline( 395 sort: anyNamed('sort'), 396 timeframe: anyNamed('timeframe'), 397 limit: anyNamed('limit'), 398 cursor: anyNamed('cursor'), 399 ), 400 ).thenAnswer((_) async => successResponse); 401 402 await feedProvider.retry(); 403 404 expect(feedProvider.error, null); 405 expect(feedProvider.posts.length, 1); 406 }); 407 }); 408 409 group('State Management', () { 410 test('should notify listeners on state change', () async { 411 var notificationCount = 0; 412 feedProvider.addListener(() { 413 notificationCount++; 414 }); 415 416 final mockResponse = TimelineResponse( 417 feed: [_createMockPost()], 418 cursor: 'cursor', 419 ); 420 421 when( 422 mockApiService.getTimeline( 423 sort: anyNamed('sort'), 424 timeframe: anyNamed('timeframe'), 425 limit: anyNamed('limit'), 426 cursor: anyNamed('cursor'), 427 ), 428 ).thenAnswer((_) async => mockResponse); 429 430 await feedProvider.fetchTimeline(refresh: true); 431 432 expect(notificationCount, greaterThan(0)); 433 }); 434 435 test('should manage loading states correctly', () async { 436 final mockResponse = TimelineResponse( 437 feed: [_createMockPost()], 438 cursor: 'cursor', 439 ); 440 441 when( 442 mockApiService.getTimeline( 443 sort: anyNamed('sort'), 444 timeframe: anyNamed('timeframe'), 445 limit: anyNamed('limit'), 446 cursor: anyNamed('cursor'), 447 ), 448 ).thenAnswer((_) async { 449 await Future.delayed(const Duration(milliseconds: 100)); 450 return mockResponse; 451 }); 452 453 final loadFuture = feedProvider.fetchTimeline(refresh: true); 454 455 // Should be loading 456 expect(feedProvider.isLoading, true); 457 458 await loadFuture; 459 460 // Should not be loading anymore 461 expect(feedProvider.isLoading, false); 462 }); 463 }); 464 465 group('Vote state initialization from viewer data', () { 466 late MockVoteProvider mockVoteProvider; 467 late FeedProvider feedProviderWithVotes; 468 469 setUp(() { 470 mockVoteProvider = MockVoteProvider(); 471 feedProviderWithVotes = FeedProvider( 472 mockAuthProvider, 473 apiService: mockApiService, 474 voteProvider: mockVoteProvider, 475 ); 476 }); 477 478 tearDown(() { 479 feedProviderWithVotes.dispose(); 480 }); 481 482 test('should initialize vote state when viewer.vote is "up"', () async { 483 when(mockAuthProvider.isAuthenticated).thenReturn(true); 484 485 final mockResponse = TimelineResponse( 486 feed: [ 487 _createMockPostWithViewer( 488 uri: 'at://did:plc:test/social.coves.post.record/1', 489 vote: 'up', 490 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 491 ), 492 ], 493 cursor: 'cursor', 494 ); 495 496 when( 497 mockApiService.getTimeline( 498 sort: anyNamed('sort'), 499 timeframe: anyNamed('timeframe'), 500 limit: anyNamed('limit'), 501 cursor: anyNamed('cursor'), 502 ), 503 ).thenAnswer((_) async => mockResponse); 504 505 await feedProviderWithVotes.fetchTimeline(refresh: true); 506 507 verify( 508 mockVoteProvider.setInitialVoteState( 509 postUri: 'at://did:plc:test/social.coves.post.record/1', 510 voteDirection: 'up', 511 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 512 ), 513 ).called(1); 514 }); 515 516 test('should initialize vote state when viewer.vote is "down"', () async { 517 when(mockAuthProvider.isAuthenticated).thenReturn(true); 518 519 final mockResponse = TimelineResponse( 520 feed: [ 521 _createMockPostWithViewer( 522 uri: 'at://did:plc:test/social.coves.post.record/1', 523 vote: 'down', 524 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 525 ), 526 ], 527 cursor: 'cursor', 528 ); 529 530 when( 531 mockApiService.getTimeline( 532 sort: anyNamed('sort'), 533 timeframe: anyNamed('timeframe'), 534 limit: anyNamed('limit'), 535 cursor: anyNamed('cursor'), 536 ), 537 ).thenAnswer((_) async => mockResponse); 538 539 await feedProviderWithVotes.fetchTimeline(refresh: true); 540 541 verify( 542 mockVoteProvider.setInitialVoteState( 543 postUri: 'at://did:plc:test/social.coves.post.record/1', 544 voteDirection: 'down', 545 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 546 ), 547 ).called(1); 548 }); 549 550 test( 551 'should clear stale vote state when viewer.vote is null on refresh', 552 () async { 553 when(mockAuthProvider.isAuthenticated).thenReturn(true); 554 555 // Feed item with null vote (user removed vote on another device) 556 final mockResponse = TimelineResponse( 557 feed: [ 558 _createMockPostWithViewer( 559 uri: 'at://did:plc:test/social.coves.post.record/1', 560 vote: null, 561 voteUri: null, 562 ), 563 ], 564 cursor: 'cursor', 565 ); 566 567 when( 568 mockApiService.getTimeline( 569 sort: anyNamed('sort'), 570 timeframe: anyNamed('timeframe'), 571 limit: anyNamed('limit'), 572 cursor: anyNamed('cursor'), 573 ), 574 ).thenAnswer((_) async => mockResponse); 575 576 await feedProviderWithVotes.fetchTimeline(refresh: true); 577 578 // Should call setInitialVoteState with null to clear stale state 579 verify( 580 mockVoteProvider.setInitialVoteState( 581 postUri: 'at://did:plc:test/social.coves.post.record/1', 582 voteDirection: null, 583 voteUri: null, 584 ), 585 ).called(1); 586 }, 587 ); 588 589 test( 590 'should initialize vote state for all feed items including no viewer', 591 () async { 592 when(mockAuthProvider.isAuthenticated).thenReturn(true); 593 594 final mockResponse = TimelineResponse( 595 feed: [ 596 _createMockPostWithViewer( 597 uri: 'at://did:plc:test/social.coves.post.record/1', 598 vote: 'up', 599 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 600 ), 601 _createMockPost(), // No viewer state 602 ], 603 cursor: 'cursor', 604 ); 605 606 when( 607 mockApiService.getTimeline( 608 sort: anyNamed('sort'), 609 timeframe: anyNamed('timeframe'), 610 limit: anyNamed('limit'), 611 cursor: anyNamed('cursor'), 612 ), 613 ).thenAnswer((_) async => mockResponse); 614 615 await feedProviderWithVotes.fetchTimeline(refresh: true); 616 617 // Should be called for both posts 618 verify( 619 mockVoteProvider.setInitialVoteState( 620 postUri: anyNamed('postUri'), 621 voteDirection: anyNamed('voteDirection'), 622 voteUri: anyNamed('voteUri'), 623 ), 624 ).called(2); 625 }, 626 ); 627 628 test('should not initialize vote state when not authenticated', () async { 629 when(mockAuthProvider.isAuthenticated).thenReturn(false); 630 631 final mockResponse = TimelineResponse( 632 feed: [ 633 _createMockPostWithViewer( 634 uri: 'at://did:plc:test/social.coves.post.record/1', 635 vote: 'up', 636 voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 637 ), 638 ], 639 cursor: 'cursor', 640 ); 641 642 when( 643 mockApiService.getDiscover( 644 sort: anyNamed('sort'), 645 timeframe: anyNamed('timeframe'), 646 limit: anyNamed('limit'), 647 cursor: anyNamed('cursor'), 648 ), 649 ).thenAnswer((_) async => mockResponse); 650 651 await feedProviderWithVotes.fetchDiscover(refresh: true); 652 653 // Should NOT call setInitialVoteState when not authenticated 654 verifyNever( 655 mockVoteProvider.setInitialVoteState( 656 postUri: anyNamed('postUri'), 657 voteDirection: anyNamed('voteDirection'), 658 voteUri: anyNamed('voteUri'), 659 ), 660 ); 661 }); 662 }); 663 }); 664} 665 666// Helper function to create mock posts 667FeedViewPost _createMockPost() { 668 return FeedViewPost( 669 post: PostView( 670 uri: 'at://did:plc:test/app.bsky.feed.post/test', 671 cid: 'test-cid', 672 rkey: 'test-rkey', 673 author: AuthorView( 674 did: 'did:plc:author', 675 handle: 'test.user', 676 displayName: 'Test User', 677 ), 678 community: CommunityRef(did: 'did:plc:community', name: 'test-community'), 679 createdAt: DateTime.parse('2025-01-01T12:00:00Z'), 680 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), 681 text: 'Test body', 682 title: 'Test Post', 683 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5), 684 facets: [], 685 ), 686 ); 687} 688 689// Helper function to create mock posts with viewer state 690FeedViewPost _createMockPostWithViewer({ 691 required String uri, 692 String? vote, 693 String? voteUri, 694}) { 695 return FeedViewPost( 696 post: PostView( 697 uri: uri, 698 cid: 'test-cid', 699 rkey: 'test-rkey', 700 author: AuthorView( 701 did: 'did:plc:author', 702 handle: 'test.user', 703 displayName: 'Test User', 704 ), 705 community: CommunityRef(did: 'did:plc:community', name: 'test-community'), 706 createdAt: DateTime.parse('2025-01-01T12:00:00Z'), 707 indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), 708 text: 'Test body', 709 title: 'Test Post', 710 stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5), 711 facets: [], 712 viewer: ViewerState(vote: vote, voteUri: voteUri), 713 ), 714 ); 715}