1import 'package:coves_flutter/providers/auth_provider.dart'; 2import 'package:coves_flutter/providers/vote_provider.dart'; 3import 'package:coves_flutter/services/api_exceptions.dart'; 4import 'package:coves_flutter/services/vote_service.dart'; 5import 'package:flutter_test/flutter_test.dart'; 6import 'package:mockito/annotations.dart'; 7import 'package:mockito/mockito.dart'; 8 9import 'vote_provider_test.mocks.dart'; 10 11// Generate mocks for VoteService and AuthProvider 12@GenerateMocks([VoteService, AuthProvider]) 13void main() { 14 TestWidgetsFlutterBinding.ensureInitialized(); 15 16 group('VoteProvider', () { 17 late VoteProvider voteProvider; 18 late MockVoteService mockVoteService; 19 late MockAuthProvider mockAuthProvider; 20 21 setUp(() { 22 mockVoteService = MockVoteService(); 23 mockAuthProvider = MockAuthProvider(); 24 25 // Default: user is authenticated 26 when(mockAuthProvider.isAuthenticated).thenReturn(true); 27 28 voteProvider = VoteProvider( 29 voteService: mockVoteService, 30 authProvider: mockAuthProvider, 31 ); 32 }); 33 34 tearDown(() { 35 voteProvider.dispose(); 36 }); 37 38 group('toggleVote', () { 39 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 40 const testPostCid = 'bafy2bzacepostcid123'; 41 42 test('should create vote with optimistic update', () async { 43 // Mock successful API response 44 when( 45 mockVoteService.createVote( 46 postUri: anyNamed('postUri'), 47 postCid: anyNamed('postCid'), 48 direction: anyNamed('direction'), 49 existingVoteRkey: anyNamed('existingVoteRkey'), 50 existingVoteDirection: anyNamed('existingVoteDirection'), 51 ), 52 ).thenAnswer( 53 (_) async => const VoteResponse( 54 uri: 'at://did:plc:test/social.coves.feed.vote/456', 55 cid: 'bafy123', 56 rkey: '456', 57 deleted: false, 58 ), 59 ); 60 61 var notificationCount = 0; 62 voteProvider.addListener(() { 63 notificationCount++; 64 }); 65 66 // Initially not liked 67 expect(voteProvider.isLiked(testPostUri), false); 68 69 // Toggle vote 70 final wasLiked = await voteProvider.toggleVote( 71 postUri: testPostUri, 72 postCid: testPostCid, 73 ); 74 75 // Should return true (vote created) 76 expect(wasLiked, true); 77 78 // Should be liked now 79 expect(voteProvider.isLiked(testPostUri), true); 80 81 // Should have notified listeners twice (optimistic + server response) 82 expect(notificationCount, greaterThanOrEqualTo(2)); 83 84 // Vote state should be correct 85 final voteState = voteProvider.getVoteState(testPostUri); 86 expect(voteState?.direction, 'up'); 87 expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456'); 88 expect(voteState?.deleted, false); 89 }); 90 91 test('should remove vote when toggled off', () async { 92 // First, set up initial vote state 93 voteProvider.setInitialVoteState( 94 postUri: testPostUri, 95 voteDirection: 'up', 96 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 97 ); 98 99 expect(voteProvider.isLiked(testPostUri), true); 100 101 // Mock API response for toggling off 102 when( 103 mockVoteService.createVote( 104 postUri: anyNamed('postUri'), 105 postCid: anyNamed('postCid'), 106 direction: anyNamed('direction'), 107 existingVoteRkey: anyNamed('existingVoteRkey'), 108 existingVoteDirection: anyNamed('existingVoteDirection'), 109 ), 110 ).thenAnswer((_) async => const VoteResponse(deleted: true)); 111 112 // Toggle vote off 113 final wasLiked = await voteProvider.toggleVote( 114 postUri: testPostUri, 115 postCid: testPostCid, 116 ); 117 118 // Should return false (vote removed) 119 expect(wasLiked, false); 120 121 // Should not be liked anymore 122 expect(voteProvider.isLiked(testPostUri), false); 123 124 // Vote state should be marked as deleted 125 final voteState = voteProvider.getVoteState(testPostUri); 126 expect(voteState?.deleted, true); 127 }); 128 129 test('should rollback on API error', () async { 130 // Set up initial state (not voted) 131 expect(voteProvider.isLiked(testPostUri), false); 132 133 // Mock API failure 134 when( 135 mockVoteService.createVote( 136 postUri: anyNamed('postUri'), 137 postCid: anyNamed('postCid'), 138 direction: anyNamed('direction'), 139 existingVoteRkey: anyNamed('existingVoteRkey'), 140 existingVoteDirection: anyNamed('existingVoteDirection'), 141 ), 142 ).thenThrow(ApiException('Network error', statusCode: 500)); 143 144 var notificationCount = 0; 145 voteProvider.addListener(() { 146 notificationCount++; 147 }); 148 149 // Try to toggle vote 150 expect( 151 () => voteProvider.toggleVote( 152 postUri: testPostUri, 153 postCid: testPostCid, 154 ), 155 throwsA(isA<ApiException>()), 156 ); 157 158 // Should rollback to initial state (not liked) 159 await Future.delayed(Duration.zero); // Wait for async completion 160 expect(voteProvider.isLiked(testPostUri), false); 161 expect(voteProvider.getVoteState(testPostUri), null); 162 163 // Should have notified listeners (optimistic + rollback) 164 expect(notificationCount, greaterThanOrEqualTo(2)); 165 }); 166 167 test('should rollback to previous state on error', () async { 168 // Set up initial voted state 169 voteProvider.setInitialVoteState( 170 postUri: testPostUri, 171 voteDirection: 'up', 172 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 173 ); 174 175 final initialState = voteProvider.getVoteState(testPostUri); 176 expect(voteProvider.isLiked(testPostUri), true); 177 178 // Mock API failure when trying to toggle off 179 when( 180 mockVoteService.createVote( 181 postUri: anyNamed('postUri'), 182 postCid: anyNamed('postCid'), 183 direction: anyNamed('direction'), 184 existingVoteRkey: anyNamed('existingVoteRkey'), 185 existingVoteDirection: anyNamed('existingVoteDirection'), 186 ), 187 ).thenThrow(NetworkException('Connection failed')); 188 189 // Try to toggle vote off 190 expect( 191 () => voteProvider.toggleVote( 192 postUri: testPostUri, 193 postCid: testPostCid, 194 ), 195 throwsA(isA<ApiException>()), 196 ); 197 198 // Should rollback to initial liked state 199 await Future.delayed(Duration.zero); // Wait for async completion 200 expect(voteProvider.isLiked(testPostUri), true); 201 expect(voteProvider.getVoteState(testPostUri)?.uri, initialState?.uri); 202 }); 203 204 test('should prevent concurrent requests for same post', () async { 205 // Mock slow API response 206 when( 207 mockVoteService.createVote( 208 postUri: anyNamed('postUri'), 209 postCid: anyNamed('postCid'), 210 direction: anyNamed('direction'), 211 existingVoteRkey: anyNamed('existingVoteRkey'), 212 existingVoteDirection: anyNamed('existingVoteDirection'), 213 ), 214 ).thenAnswer((_) async { 215 await Future.delayed(const Duration(milliseconds: 100)); 216 return const VoteResponse( 217 uri: 'at://did:plc:test/social.coves.feed.vote/456', 218 cid: 'bafy123', 219 rkey: '456', 220 deleted: false, 221 ); 222 }); 223 224 // Start first request 225 final future1 = voteProvider.toggleVote( 226 postUri: testPostUri, 227 postCid: testPostCid, 228 ); 229 230 // Try to start second request before first completes 231 final result2 = await voteProvider.toggleVote( 232 postUri: testPostUri, 233 postCid: testPostCid, 234 ); 235 236 // Second request should be ignored 237 expect(result2, false); 238 239 // First request should complete normally 240 final result1 = await future1; 241 expect(result1, true); 242 243 // Should have only called API once 244 verify( 245 mockVoteService.createVote( 246 postUri: anyNamed('postUri'), 247 postCid: anyNamed('postCid'), 248 direction: anyNamed('direction'), 249 existingVoteRkey: anyNamed('existingVoteRkey'), 250 existingVoteDirection: anyNamed('existingVoteDirection'), 251 ), 252 ).called(1); 253 }); 254 255 test('should handle downvote direction', () async { 256 when( 257 mockVoteService.createVote( 258 postUri: anyNamed('postUri'), 259 postCid: anyNamed('postCid'), 260 direction: anyNamed('direction'), 261 existingVoteRkey: anyNamed('existingVoteRkey'), 262 existingVoteDirection: anyNamed('existingVoteDirection'), 263 ), 264 ).thenAnswer( 265 (_) async => const VoteResponse( 266 uri: 'at://did:plc:test/social.coves.feed.vote/456', 267 cid: 'bafy123', 268 rkey: '456', 269 deleted: false, 270 ), 271 ); 272 273 await voteProvider.toggleVote( 274 postUri: testPostUri, 275 postCid: testPostCid, 276 direction: 'down', 277 ); 278 279 final voteState = voteProvider.getVoteState(testPostUri); 280 expect(voteState?.direction, 'down'); 281 expect(voteState?.deleted, false); 282 283 // Should not be "liked" (isLiked checks for 'up' direction) 284 expect(voteProvider.isLiked(testPostUri), false); 285 }); 286 }); 287 288 group('setInitialVoteState', () { 289 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 290 291 test('should set initial vote state from API data', () { 292 voteProvider.setInitialVoteState( 293 postUri: testPostUri, 294 voteDirection: 'up', 295 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 296 ); 297 298 expect(voteProvider.isLiked(testPostUri), true); 299 300 final voteState = voteProvider.getVoteState(testPostUri); 301 expect(voteState?.direction, 'up'); 302 expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456'); 303 expect(voteState?.deleted, false); 304 }); 305 306 test('should remove vote state when voteDirection is null', () { 307 // First set a vote 308 voteProvider.setInitialVoteState( 309 postUri: testPostUri, 310 voteDirection: 'up', 311 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 312 ); 313 314 expect(voteProvider.isLiked(testPostUri), true); 315 316 // Then clear it 317 voteProvider.setInitialVoteState(postUri: testPostUri); 318 319 expect(voteProvider.isLiked(testPostUri), false); 320 expect(voteProvider.getVoteState(testPostUri), null); 321 }); 322 323 test('should not notify listeners when setting initial state', () { 324 var notificationCount = 0; 325 voteProvider 326 ..addListener(() { 327 notificationCount++; 328 }) 329 ..setInitialVoteState( 330 postUri: testPostUri, 331 voteDirection: 'up', 332 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 333 ); 334 335 // Should NOT notify listeners (silent initialization) 336 expect(notificationCount, 0); 337 }); 338 }); 339 340 group('clear', () { 341 test('should clear all vote state', () { 342 const post1 = 'at://did:plc:test/social.coves.post.record/1'; 343 const post2 = 'at://did:plc:test/social.coves.post.record/2'; 344 345 // Set up multiple votes 346 voteProvider 347 ..setInitialVoteState( 348 postUri: post1, 349 voteDirection: 'up', 350 voteUri: 'at://did:plc:test/social.coves.feed.vote/1', 351 ) 352 ..setInitialVoteState( 353 postUri: post2, 354 voteDirection: 'up', 355 voteUri: 'at://did:plc:test/social.coves.feed.vote/2', 356 ); 357 358 expect(voteProvider.isLiked(post1), true); 359 expect(voteProvider.isLiked(post2), true); 360 361 // Clear all 362 voteProvider.clear(); 363 364 // Should have no votes 365 expect(voteProvider.isLiked(post1), false); 366 expect(voteProvider.isLiked(post2), false); 367 expect(voteProvider.getVoteState(post1), null); 368 expect(voteProvider.getVoteState(post2), null); 369 }); 370 371 test('should notify listeners when cleared', () { 372 var notificationCount = 0; 373 voteProvider 374 ..addListener(() { 375 notificationCount++; 376 }) 377 ..clear(); 378 379 expect(notificationCount, 1); 380 }); 381 }); 382 383 group('isPending', () { 384 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 385 const testPostCid = 'bafy2bzacepostcid123'; 386 387 test('should return true while request is in progress', () async { 388 // Mock slow API response 389 when( 390 mockVoteService.createVote( 391 postUri: anyNamed('postUri'), 392 postCid: anyNamed('postCid'), 393 direction: anyNamed('direction'), 394 existingVoteRkey: anyNamed('existingVoteRkey'), 395 existingVoteDirection: anyNamed('existingVoteDirection'), 396 ), 397 ).thenAnswer((_) async { 398 await Future.delayed(const Duration(milliseconds: 50)); 399 return const VoteResponse( 400 uri: 'at://did:plc:test/social.coves.feed.vote/456', 401 cid: 'bafy123', 402 rkey: '456', 403 deleted: false, 404 ); 405 }); 406 407 expect(voteProvider.isPending(testPostUri), false); 408 409 // Start request 410 final future = voteProvider.toggleVote( 411 postUri: testPostUri, 412 postCid: testPostCid, 413 ); 414 415 // Give it time to set pending flag 416 await Future.delayed(const Duration(milliseconds: 10)); 417 418 // Should be pending now 419 expect(voteProvider.isPending(testPostUri), true); 420 421 // Wait for completion 422 await future; 423 424 // Should not be pending anymore 425 expect(voteProvider.isPending(testPostUri), false); 426 }); 427 428 test('should return false for posts with no pending request', () { 429 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 430 expect(voteProvider.isPending(testPostUri), false); 431 }); 432 }); 433 434 group('Score adjustments', () { 435 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 436 const testPostCid = 'bafy2bzacepostcid123'; 437 438 test('should adjust score when creating upvote', () async { 439 when( 440 mockVoteService.createVote( 441 postUri: anyNamed('postUri'), 442 postCid: anyNamed('postCid'), 443 direction: anyNamed('direction'), 444 existingVoteRkey: anyNamed('existingVoteRkey'), 445 existingVoteDirection: anyNamed('existingVoteDirection'), 446 ), 447 ).thenAnswer( 448 (_) async => const VoteResponse( 449 uri: 'at://did:plc:test/social.coves.feed.vote/456', 450 cid: 'bafy123', 451 rkey: '456', 452 deleted: false, 453 ), 454 ); 455 456 // Initial score from server 457 const serverScore = 10; 458 459 // Before vote, adjustment should be 0 460 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10); 461 462 // Create upvote 463 await voteProvider.toggleVote( 464 postUri: testPostUri, 465 postCid: testPostCid, 466 ); 467 468 // Should have +1 adjustment (upvote added) 469 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 11); 470 }); 471 472 test('should adjust score when removing upvote', () async { 473 // Set initial state with upvote 474 voteProvider.setInitialVoteState( 475 postUri: testPostUri, 476 voteDirection: 'up', 477 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 478 ); 479 480 when( 481 mockVoteService.createVote( 482 postUri: anyNamed('postUri'), 483 postCid: anyNamed('postCid'), 484 direction: anyNamed('direction'), 485 existingVoteRkey: anyNamed('existingVoteRkey'), 486 existingVoteDirection: anyNamed('existingVoteDirection'), 487 ), 488 ).thenAnswer((_) async => const VoteResponse(deleted: true)); 489 490 const serverScore = 10; 491 492 // Before removing, adjustment should be 0 (server knows about upvote) 493 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10); 494 495 // Remove upvote 496 await voteProvider.toggleVote( 497 postUri: testPostUri, 498 postCid: testPostCid, 499 ); 500 501 // Should have -1 adjustment (upvote removed) 502 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9); 503 }); 504 505 test('should adjust score when creating downvote', () async { 506 when( 507 mockVoteService.createVote( 508 postUri: anyNamed('postUri'), 509 postCid: anyNamed('postCid'), 510 direction: anyNamed('direction'), 511 existingVoteRkey: anyNamed('existingVoteRkey'), 512 existingVoteDirection: anyNamed('existingVoteDirection'), 513 ), 514 ).thenAnswer( 515 (_) async => const VoteResponse( 516 uri: 'at://did:plc:test/social.coves.feed.vote/456', 517 cid: 'bafy123', 518 rkey: '456', 519 deleted: false, 520 ), 521 ); 522 523 const serverScore = 10; 524 525 // Create downvote 526 await voteProvider.toggleVote( 527 postUri: testPostUri, 528 postCid: testPostCid, 529 direction: 'down', 530 ); 531 532 // Should have -1 adjustment (downvote added) 533 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9); 534 }); 535 536 test( 537 'should adjust score when switching from upvote to downvote', 538 () async { 539 // Set initial state with upvote 540 voteProvider.setInitialVoteState( 541 postUri: testPostUri, 542 voteDirection: 'up', 543 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 544 ); 545 546 when( 547 mockVoteService.createVote( 548 postUri: anyNamed('postUri'), 549 postCid: anyNamed('postCid'), 550 direction: anyNamed('direction'), 551 existingVoteRkey: anyNamed('existingVoteRkey'), 552 existingVoteDirection: anyNamed('existingVoteDirection'), 553 ), 554 ).thenAnswer( 555 (_) async => const VoteResponse( 556 uri: 'at://did:plc:test/social.coves.feed.vote/789', 557 cid: 'bafy789', 558 rkey: '789', 559 deleted: false, 560 ), 561 ); 562 563 const serverScore = 10; 564 565 // Switch to downvote 566 await voteProvider.toggleVote( 567 postUri: testPostUri, 568 postCid: testPostCid, 569 direction: 'down', 570 ); 571 572 // Should have -2 adjustment (remove +1, add -1) 573 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 8); 574 }, 575 ); 576 577 test( 578 'should adjust score when switching from downvote to upvote', 579 () async { 580 // Set initial state with downvote 581 voteProvider.setInitialVoteState( 582 postUri: testPostUri, 583 voteDirection: 'down', 584 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 585 ); 586 587 when( 588 mockVoteService.createVote( 589 postUri: anyNamed('postUri'), 590 postCid: anyNamed('postCid'), 591 direction: anyNamed('direction'), 592 existingVoteRkey: anyNamed('existingVoteRkey'), 593 existingVoteDirection: anyNamed('existingVoteDirection'), 594 ), 595 ).thenAnswer( 596 (_) async => const VoteResponse( 597 uri: 'at://did:plc:test/social.coves.feed.vote/789', 598 cid: 'bafy789', 599 rkey: '789', 600 deleted: false, 601 ), 602 ); 603 604 const serverScore = 10; 605 606 // Switch to upvote 607 await voteProvider.toggleVote( 608 postUri: testPostUri, 609 postCid: testPostCid, 610 ); 611 612 // Should have +2 adjustment (remove -1, add +1) 613 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 12); 614 }, 615 ); 616 617 test('should rollback score adjustment on error', () async { 618 const serverScore = 10; 619 620 when( 621 mockVoteService.createVote( 622 postUri: anyNamed('postUri'), 623 postCid: anyNamed('postCid'), 624 direction: anyNamed('direction'), 625 existingVoteRkey: anyNamed('existingVoteRkey'), 626 existingVoteDirection: anyNamed('existingVoteDirection'), 627 ), 628 ).thenThrow(ApiException('Network error', statusCode: 500)); 629 630 // Try to vote (will fail) 631 expect( 632 () => voteProvider.toggleVote( 633 postUri: testPostUri, 634 postCid: testPostCid, 635 ), 636 throwsA(isA<ApiException>()), 637 ); 638 639 await Future.delayed(Duration.zero); 640 641 // Adjustment should be rolled back to 0 642 expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 10); 643 }); 644 645 test('should clear score adjustments when clearing all state', () { 646 const testPostUri1 = 'at://did:plc:test/social.coves.post.record/1'; 647 const testPostUri2 = 'at://did:plc:test/social.coves.post.record/2'; 648 649 // Manually set some adjustments (simulating votes) 650 voteProvider 651 ..setInitialVoteState( 652 postUri: testPostUri1, 653 voteDirection: 'up', 654 voteUri: 'at://did:plc:test/social.coves.feed.vote/1', 655 ) 656 ..clear(); 657 658 // Adjustments should be cleared (back to 0) 659 expect(voteProvider.getAdjustedScore(testPostUri1, 10), 10); 660 expect(voteProvider.getAdjustedScore(testPostUri2, 5), 5); 661 }); 662 }); 663 664 group('Auth state listener', () { 665 test('should clear votes when user signs out', () { 666 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 667 668 // Set up vote state 669 voteProvider.setInitialVoteState( 670 postUri: testPostUri, 671 voteDirection: 'up', 672 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 673 ); 674 675 expect(voteProvider.isLiked(testPostUri), true); 676 677 // Simulate sign out by changing auth state 678 when(mockAuthProvider.isAuthenticated).thenReturn(false); 679 680 // Trigger the auth listener by calling it directly 681 // (In real app, this would be triggered by 682 // AuthProvider.notifyListeners) 683 voteProvider.clear(); 684 685 // Votes should be cleared 686 expect(voteProvider.isLiked(testPostUri), false); 687 expect(voteProvider.getVoteState(testPostUri), null); 688 }); 689 690 test('should not clear votes when user is still authenticated', () { 691 const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 692 693 // Set up vote state 694 voteProvider.setInitialVoteState( 695 postUri: testPostUri, 696 voteDirection: 'up', 697 voteUri: 'at://did:plc:test/social.coves.feed.vote/456', 698 ); 699 700 expect(voteProvider.isLiked(testPostUri), true); 701 702 // Auth state remains authenticated 703 when(mockAuthProvider.isAuthenticated).thenReturn(true); 704 705 // Votes should NOT be cleared 706 expect(voteProvider.isLiked(testPostUri), true); 707 }); 708 }); 709 }); 710}