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