1import 'package:coves_flutter/models/coves_session.dart'; 2import 'package:coves_flutter/services/coves_auth_service.dart'; 3import 'package:dio/dio.dart'; 4import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 5import 'package:flutter_test/flutter_test.dart'; 6import 'package:mockito/annotations.dart'; 7import 'package:mockito/mockito.dart'; 8 9import 'coves_auth_service_test.mocks.dart'; 10 11@GenerateMocks([Dio, FlutterSecureStorage]) 12void main() { 13 late MockDio mockDio; 14 late MockFlutterSecureStorage mockStorage; 15 late CovesAuthService authService; 16 17 // Storage key is environment-specific to prevent token reuse across dev/prod 18 // Tests run in production environment by default 19 const storageKey = 'coves_session_production'; 20 21 setUp(() { 22 CovesAuthService.resetInstance(); 23 mockDio = MockDio(); 24 mockStorage = MockFlutterSecureStorage(); 25 authService = CovesAuthService.createTestInstance( 26 dio: mockDio, 27 storage: mockStorage, 28 ); 29 }); 30 31 tearDown(() { 32 CovesAuthService.resetInstance(); 33 }); 34 35 group('CovesAuthService', () { 36 group('signIn()', () { 37 test('should throw ArgumentError when handle is empty', () async { 38 expect(() => authService.signIn(''), throwsA(isA<ArgumentError>())); 39 }); 40 41 test( 42 'should throw ArgumentError when handle is whitespace-only', 43 () async { 44 expect( 45 () => authService.signIn(' '), 46 throwsA(isA<ArgumentError>()), 47 ); 48 }, 49 ); 50 51 test('should throw appropriate error when user cancels sign-in', () async { 52 // Note: FlutterWebAuth2.authenticate is not easily mockable as it's a static method 53 // This test documents expected behavior when authentication is cancelled 54 // In practice, this would throw with CANCELED/cancelled in the message 55 // The actual implementation catches this and rethrows with user-friendly message 56 57 // This test would require integration testing or a wrapper around FlutterWebAuth2 58 // Skipping for now as it requires more complex mocking infrastructure 59 }); 60 61 test( 62 'should throw Exception when network error occurs during OAuth', 63 () async { 64 // Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock 65 // This test documents expected behavior 66 // The actual implementation catches exceptions and rethrows with context 67 }, 68 ); 69 70 test('should trim handle before processing', () async { 71 // This test verifies the handle trimming logic 72 // The actual OAuth flow is tested via integration tests 73 const handle = ' alice.bsky.social '; 74 expect(handle.trim(), 'alice.bsky.social'); 75 }); 76 }); 77 78 group('restoreSession()', () { 79 test('should successfully restore valid session from storage', () async { 80 // Arrange 81 const session = CovesSession( 82 token: 'test-token', 83 did: 'did:plc:test123', 84 sessionId: 'session-123', 85 handle: 'alice.bsky.social', 86 ); 87 final jsonString = session.toJsonString(); 88 89 when( 90 mockStorage.read(key: storageKey), 91 ).thenAnswer((_) async => jsonString); 92 93 // Act 94 final result = await authService.restoreSession(); 95 96 // Assert 97 expect(result, isNotNull); 98 expect(result!.token, 'test-token'); 99 expect(result.did, 'did:plc:test123'); 100 expect(result.sessionId, 'session-123'); 101 expect(result.handle, 'alice.bsky.social'); 102 verify(mockStorage.read(key: storageKey)).called(1); 103 }); 104 105 test('should return null when no stored session exists', () async { 106 // Arrange 107 when(mockStorage.read(key: storageKey)).thenAnswer((_) async => null); 108 109 // Act 110 final result = await authService.restoreSession(); 111 112 // Assert 113 expect(result, isNull); 114 verify(mockStorage.read(key: storageKey)).called(1); 115 }); 116 117 test('should handle corrupted storage data gracefully', () async { 118 // Arrange 119 const corruptedJson = 'not-valid-json{]'; 120 when( 121 mockStorage.read(key: storageKey), 122 ).thenAnswer((_) async => corruptedJson); 123 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 124 125 // Act 126 final result = await authService.restoreSession(); 127 128 // Assert 129 expect(result, isNull); 130 verify(mockStorage.read(key: storageKey)).called(1); 131 verify(mockStorage.delete(key: storageKey)).called(1); 132 }); 133 134 test( 135 'should handle session JSON with missing required fields gracefully', 136 () async { 137 // Arrange 138 const invalidJson = 139 '{"token": "test"}'; // Missing required fields (did, session_id) 140 when( 141 mockStorage.read(key: storageKey), 142 ).thenAnswer((_) async => invalidJson); 143 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 144 145 // Act 146 final result = await authService.restoreSession(); 147 148 // Assert 149 // Should return null and clear corrupted storage 150 expect(result, isNull); 151 verify(mockStorage.read(key: storageKey)).called(1); 152 verify(mockStorage.delete(key: storageKey)).called(1); 153 }, 154 ); 155 156 test('should handle storage read errors gracefully', () async { 157 // Arrange 158 when( 159 mockStorage.read(key: storageKey), 160 ).thenThrow(Exception('Storage error')); 161 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 162 163 // Act 164 final result = await authService.restoreSession(); 165 166 // Assert 167 expect(result, isNull); 168 verify(mockStorage.delete(key: storageKey)).called(1); 169 }); 170 }); 171 172 group('refreshToken()', () { 173 test('should throw StateError when no session exists', () async { 174 // Act & Assert 175 expect(() => authService.refreshToken(), throwsA(isA<StateError>())); 176 }); 177 178 test( 179 'should successfully refresh token and return updated session', 180 () async { 181 // Arrange - First restore a session 182 const initialSession = CovesSession( 183 token: 'old-token', 184 did: 'did:plc:test123', 185 sessionId: 'session-123', 186 handle: 'alice.bsky.social', 187 ); 188 when( 189 mockStorage.read(key: storageKey), 190 ).thenAnswer((_) async => initialSession.toJsonString()); 191 await authService.restoreSession(); 192 193 // Mock successful refresh response 194 const newToken = 'new-refreshed-token'; 195 when( 196 mockDio.post<Map<String, dynamic>>( 197 '/oauth/refresh', 198 data: anyNamed('data'), 199 ), 200 ).thenAnswer( 201 (_) async => Response( 202 requestOptions: RequestOptions(path: '/oauth/refresh'), 203 statusCode: 200, 204 data: { 205 'sealed_token': newToken, 206 'access_token': 'some-access-token', 207 }, 208 ), 209 ); 210 211 when( 212 mockStorage.write(key: storageKey, value: anyNamed('value')), 213 ).thenAnswer((_) async => {}); 214 215 // Act 216 final result = await authService.refreshToken(); 217 218 // Assert 219 expect(result.token, newToken); 220 expect(result.did, 'did:plc:test123'); 221 expect(result.sessionId, 'session-123'); 222 expect(result.handle, 'alice.bsky.social'); 223 verify( 224 mockDio.post<Map<String, dynamic>>( 225 '/oauth/refresh', 226 data: anyNamed('data'), 227 ), 228 ).called(1); 229 verify( 230 mockStorage.write(key: storageKey, value: anyNamed('value')), 231 ).called(1); 232 }, 233 ); 234 235 test('should throw "Session expired" on 401 response', () async { 236 // Arrange - First restore a session 237 const session = CovesSession( 238 token: 'old-token', 239 did: 'did:plc:test123', 240 sessionId: 'session-123', 241 ); 242 when( 243 mockStorage.read(key: storageKey), 244 ).thenAnswer((_) async => session.toJsonString()); 245 await authService.restoreSession(); 246 247 // Mock 401 response 248 when( 249 mockDio.post<Map<String, dynamic>>( 250 '/oauth/refresh', 251 data: anyNamed('data'), 252 ), 253 ).thenThrow( 254 DioException( 255 requestOptions: RequestOptions(path: '/oauth/refresh'), 256 type: DioExceptionType.badResponse, 257 response: Response( 258 requestOptions: RequestOptions(path: '/oauth/refresh'), 259 statusCode: 401, 260 ), 261 ), 262 ); 263 264 // Act & Assert 265 expect( 266 () => authService.refreshToken(), 267 throwsA( 268 predicate( 269 (e) => e is Exception && e.toString().contains('Session expired'), 270 ), 271 ), 272 ); 273 }); 274 275 test('should throw Exception on network error during refresh', () async { 276 // Arrange - First restore a session 277 const session = CovesSession( 278 token: 'old-token', 279 did: 'did:plc:test123', 280 sessionId: 'session-123', 281 ); 282 when( 283 mockStorage.read(key: storageKey), 284 ).thenAnswer((_) async => session.toJsonString()); 285 await authService.restoreSession(); 286 287 // Mock network error 288 when( 289 mockDio.post<Map<String, dynamic>>( 290 '/oauth/refresh', 291 data: anyNamed('data'), 292 ), 293 ).thenThrow( 294 DioException( 295 requestOptions: RequestOptions(path: '/oauth/refresh'), 296 type: DioExceptionType.connectionError, 297 message: 'Connection failed', 298 ), 299 ); 300 301 // Act & Assert 302 expect( 303 () => authService.refreshToken(), 304 throwsA( 305 predicate( 306 (e) => 307 e is Exception && 308 e.toString().contains('Token refresh failed'), 309 ), 310 ), 311 ); 312 }); 313 314 test( 315 'should throw Exception when response is missing sealed_token', 316 () async { 317 // Arrange - First restore a session 318 const session = CovesSession( 319 token: 'old-token', 320 did: 'did:plc:test123', 321 sessionId: 'session-123', 322 ); 323 when( 324 mockStorage.read(key: storageKey), 325 ).thenAnswer((_) async => session.toJsonString()); 326 await authService.restoreSession(); 327 328 // Mock response without sealed_token 329 when( 330 mockDio.post<Map<String, dynamic>>( 331 '/oauth/refresh', 332 data: anyNamed('data'), 333 ), 334 ).thenAnswer( 335 (_) async => Response( 336 requestOptions: RequestOptions(path: '/oauth/refresh'), 337 statusCode: 200, 338 data: {'access_token': 'some-token'}, // No sealed_token field 339 ), 340 ); 341 342 // Act & Assert 343 expect( 344 () => authService.refreshToken(), 345 throwsA( 346 predicate( 347 (e) => 348 e is Exception && 349 e.toString().contains('Invalid refresh response'), 350 ), 351 ), 352 ); 353 }, 354 ); 355 356 test( 357 'should throw Exception when response sealed_token is empty', 358 () async { 359 // Arrange - First restore a session 360 const session = CovesSession( 361 token: 'old-token', 362 did: 'did:plc:test123', 363 sessionId: 'session-123', 364 ); 365 when( 366 mockStorage.read(key: storageKey), 367 ).thenAnswer((_) async => session.toJsonString()); 368 await authService.restoreSession(); 369 370 // Mock response with empty sealed_token 371 when( 372 mockDio.post<Map<String, dynamic>>( 373 '/oauth/refresh', 374 data: anyNamed('data'), 375 ), 376 ).thenAnswer( 377 (_) async => Response( 378 requestOptions: RequestOptions(path: '/oauth/refresh'), 379 statusCode: 200, 380 data: { 381 'sealed_token': '', 382 'access_token': 'some-token', 383 }, // Empty sealed_token 384 ), 385 ); 386 387 // Act & Assert 388 expect( 389 () => authService.refreshToken(), 390 throwsA( 391 predicate( 392 (e) => 393 e is Exception && 394 e.toString().contains('Invalid refresh response'), 395 ), 396 ), 397 ); 398 }, 399 ); 400 }); 401 402 group('signOut()', () { 403 test( 404 'should clear session and storage on successful server-side logout', 405 () async { 406 // Arrange - First restore a session 407 const session = CovesSession( 408 token: 'test-token', 409 did: 'did:plc:test123', 410 sessionId: 'session-123', 411 ); 412 when( 413 mockStorage.read(key: storageKey), 414 ).thenAnswer((_) async => session.toJsonString()); 415 await authService.restoreSession(); 416 417 // Mock successful logout 418 when( 419 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 420 ).thenAnswer( 421 (_) async => Response( 422 requestOptions: RequestOptions(path: '/oauth/logout'), 423 statusCode: 200, 424 ), 425 ); 426 427 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 428 429 // Act 430 await authService.signOut(); 431 432 // Assert 433 expect(authService.session, isNull); 434 expect(authService.isAuthenticated, isFalse); 435 verify( 436 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 437 ).called(1); 438 verify(mockStorage.delete(key: storageKey)).called(1); 439 }, 440 ); 441 442 test( 443 'should clear local state even when server revocation fails', 444 () async { 445 // Arrange - First restore a session 446 const session = CovesSession( 447 token: 'test-token', 448 did: 'did:plc:test123', 449 sessionId: 'session-123', 450 ); 451 when( 452 mockStorage.read(key: storageKey), 453 ).thenAnswer((_) async => session.toJsonString()); 454 await authService.restoreSession(); 455 456 // Mock server error 457 when( 458 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 459 ).thenThrow( 460 DioException( 461 requestOptions: RequestOptions(path: '/oauth/logout'), 462 type: DioExceptionType.connectionError, 463 message: 'Connection failed', 464 ), 465 ); 466 467 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 468 469 // Act 470 await authService.signOut(); 471 472 // Assert 473 expect(authService.session, isNull); 474 expect(authService.isAuthenticated, isFalse); 475 verify(mockStorage.delete(key: storageKey)).called(1); 476 }, 477 ); 478 479 test('should work even when no session exists', () async { 480 // Arrange 481 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 482 483 // Act 484 await authService.signOut(); 485 486 // Assert 487 expect(authService.session, isNull); 488 expect(authService.isAuthenticated, isFalse); 489 verify(mockStorage.delete(key: storageKey)).called(1); 490 verifyNever( 491 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 492 ); 493 }); 494 495 test('should clear local state even when storage delete fails', () async { 496 // Arrange - First restore a session 497 const session = CovesSession( 498 token: 'test-token', 499 did: 'did:plc:test123', 500 sessionId: 'session-123', 501 ); 502 when( 503 mockStorage.read(key: storageKey), 504 ).thenAnswer((_) async => session.toJsonString()); 505 await authService.restoreSession(); 506 507 when( 508 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 509 ).thenAnswer( 510 (_) async => Response( 511 requestOptions: RequestOptions(path: '/oauth/logout'), 512 statusCode: 200, 513 ), 514 ); 515 516 when( 517 mockStorage.delete(key: storageKey), 518 ).thenThrow(Exception('Storage error')); 519 520 // Act & Assert - Should not throw 521 expect(() => authService.signOut(), throwsA(isA<Exception>())); 522 523 // Note: The session is cleared in memory even if storage fails 524 // This is because the finally block sets _session = null 525 }); 526 }); 527 528 group('getToken()', () { 529 test('should return token when authenticated', () async { 530 // Arrange - First restore a session 531 const session = CovesSession( 532 token: 'test-token', 533 did: 'did:plc:test123', 534 sessionId: 'session-123', 535 ); 536 when( 537 mockStorage.read(key: storageKey), 538 ).thenAnswer((_) async => session.toJsonString()); 539 await authService.restoreSession(); 540 541 // Act 542 final token = authService.getToken(); 543 544 // Assert 545 expect(token, 'test-token'); 546 }); 547 548 test('should return null when not authenticated', () { 549 // Act 550 final token = authService.getToken(); 551 552 // Assert 553 expect(token, isNull); 554 }); 555 556 test('should return null after sign out', () async { 557 // Arrange - First restore a session 558 const session = CovesSession( 559 token: 'test-token', 560 did: 'did:plc:test123', 561 sessionId: 'session-123', 562 ); 563 when( 564 mockStorage.read(key: storageKey), 565 ).thenAnswer((_) async => session.toJsonString()); 566 await authService.restoreSession(); 567 568 when( 569 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 570 ).thenAnswer( 571 (_) async => Response( 572 requestOptions: RequestOptions(path: '/oauth/logout'), 573 statusCode: 200, 574 ), 575 ); 576 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 577 578 // Act 579 await authService.signOut(); 580 final token = authService.getToken(); 581 582 // Assert 583 expect(token, isNull); 584 }); 585 }); 586 587 group('isAuthenticated', () { 588 test('should return false when no session exists', () { 589 // Assert 590 expect(authService.isAuthenticated, isFalse); 591 }); 592 593 test('should return true when session exists', () async { 594 // Arrange - Restore a session 595 const session = CovesSession( 596 token: 'test-token', 597 did: 'did:plc:test123', 598 sessionId: 'session-123', 599 ); 600 when( 601 mockStorage.read(key: storageKey), 602 ).thenAnswer((_) async => session.toJsonString()); 603 await authService.restoreSession(); 604 605 // Assert 606 expect(authService.isAuthenticated, isTrue); 607 }); 608 609 test('should return false after sign out', () async { 610 // Arrange - First restore a session 611 const session = CovesSession( 612 token: 'test-token', 613 did: 'did:plc:test123', 614 sessionId: 'session-123', 615 ); 616 when( 617 mockStorage.read(key: storageKey), 618 ).thenAnswer((_) async => session.toJsonString()); 619 await authService.restoreSession(); 620 621 when( 622 mockDio.post<void>('/oauth/logout', options: anyNamed('options')), 623 ).thenAnswer( 624 (_) async => Response( 625 requestOptions: RequestOptions(path: '/oauth/logout'), 626 statusCode: 200, 627 ), 628 ); 629 when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); 630 631 // Act 632 await authService.signOut(); 633 634 // Assert 635 expect(authService.isAuthenticated, isFalse); 636 }); 637 }); 638 639 group('session caching', () { 640 test('should cache session in memory after restore', () async { 641 // Arrange 642 const session = CovesSession( 643 token: 'test-token', 644 did: 'did:plc:test123', 645 sessionId: 'session-123', 646 ); 647 when( 648 mockStorage.read(key: storageKey), 649 ).thenAnswer((_) async => session.toJsonString()); 650 651 // Act 652 await authService.restoreSession(); 653 654 // Assert - Accessing session property should not read from storage again 655 expect(authService.session?.token, 'test-token'); 656 expect(authService.session?.did, 'did:plc:test123'); 657 verify(mockStorage.read(key: storageKey)).called(1); 658 }); 659 660 test('should update cached session after token refresh', () async { 661 // Arrange - First restore a session 662 const initialSession = CovesSession( 663 token: 'old-token', 664 did: 'did:plc:test123', 665 sessionId: 'session-123', 666 ); 667 when( 668 mockStorage.read(key: storageKey), 669 ).thenAnswer((_) async => initialSession.toJsonString()); 670 await authService.restoreSession(); 671 672 const newToken = 'new-token'; 673 when( 674 mockDio.post<Map<String, dynamic>>( 675 '/oauth/refresh', 676 data: anyNamed('data'), 677 ), 678 ).thenAnswer( 679 (_) async => Response( 680 requestOptions: RequestOptions(path: '/oauth/refresh'), 681 statusCode: 200, 682 data: { 683 'sealed_token': newToken, 684 'access_token': 'some-access-token', 685 }, 686 ), 687 ); 688 when( 689 mockStorage.write(key: storageKey, value: anyNamed('value')), 690 ).thenAnswer((_) async => {}); 691 692 // Act 693 await authService.refreshToken(); 694 695 // Assert - Cached session should have new token 696 expect(authService.session?.token, newToken); 697 expect(authService.getToken(), newToken); 698 }); 699 }); 700 701 group('refreshToken() - Concurrency Protection', () { 702 test( 703 'should only make one API request for concurrent refresh calls', 704 () async { 705 // Arrange - First restore a session 706 const initialSession = CovesSession( 707 token: 'old-token', 708 did: 'did:plc:test123', 709 sessionId: 'session-123', 710 handle: 'alice.bsky.social', 711 ); 712 when( 713 mockStorage.read(key: storageKey), 714 ).thenAnswer((_) async => initialSession.toJsonString()); 715 await authService.restoreSession(); 716 717 const newToken = 'new-refreshed-token'; 718 719 // Mock refresh response with a delay to simulate network latency 720 when( 721 mockDio.post<Map<String, dynamic>>( 722 '/oauth/refresh', 723 data: anyNamed('data'), 724 ), 725 ).thenAnswer((_) async { 726 await Future.delayed(const Duration(milliseconds: 100)); 727 return Response( 728 requestOptions: RequestOptions(path: '/oauth/refresh'), 729 statusCode: 200, 730 data: { 731 'sealed_token': newToken, 732 'access_token': 'some-access-token', 733 }, 734 ); 735 }); 736 737 when( 738 mockStorage.write(key: storageKey, value: anyNamed('value')), 739 ).thenAnswer((_) async => {}); 740 741 // Act - Launch 3 concurrent refresh calls 742 final results = await Future.wait([ 743 authService.refreshToken(), 744 authService.refreshToken(), 745 authService.refreshToken(), 746 ]); 747 748 // Assert - All calls should return the same refreshed session 749 expect(results.length, 3); 750 expect(results[0].token, newToken); 751 expect(results[1].token, newToken); 752 expect(results[2].token, newToken); 753 754 // Verify only one API call was made 755 verify( 756 mockDio.post<Map<String, dynamic>>( 757 '/oauth/refresh', 758 data: anyNamed('data'), 759 ), 760 ).called(1); 761 762 // Verify only one storage write was made 763 verify( 764 mockStorage.write(key: storageKey, value: anyNamed('value')), 765 ).called(1); 766 }, 767 ); 768 769 test('should propagate errors to all concurrent waiters', () async { 770 // Arrange - First restore a session 771 const session = CovesSession( 772 token: 'old-token', 773 did: 'did:plc:test123', 774 sessionId: 'session-123', 775 ); 776 when( 777 mockStorage.read(key: storageKey), 778 ).thenAnswer((_) async => session.toJsonString()); 779 await authService.restoreSession(); 780 781 // Mock 401 response with delay 782 when( 783 mockDio.post<Map<String, dynamic>>( 784 '/oauth/refresh', 785 data: anyNamed('data'), 786 ), 787 ).thenAnswer((_) async { 788 await Future.delayed(const Duration(milliseconds: 100)); 789 throw DioException( 790 requestOptions: RequestOptions(path: '/oauth/refresh'), 791 type: DioExceptionType.badResponse, 792 response: Response( 793 requestOptions: RequestOptions(path: '/oauth/refresh'), 794 statusCode: 401, 795 ), 796 ); 797 }); 798 799 // Act - Start concurrent refresh calls 800 final futures = [ 801 authService.refreshToken(), 802 authService.refreshToken(), 803 authService.refreshToken(), 804 ]; 805 806 // Assert - All should throw the same error 807 var errorCount = 0; 808 for (final future in futures) { 809 try { 810 await future; 811 fail('Expected exception to be thrown'); 812 } catch (e) { 813 expect(e, isA<Exception>()); 814 expect(e.toString(), contains('Session expired')); 815 errorCount++; 816 } 817 } 818 819 expect(errorCount, 3); 820 821 // Verify only one API call was made 822 verify( 823 mockDio.post<Map<String, dynamic>>( 824 '/oauth/refresh', 825 data: anyNamed('data'), 826 ), 827 ).called(1); 828 }); 829 830 test('should allow new refresh after previous one completes', () async { 831 // Arrange - First restore a session 832 const initialSession = CovesSession( 833 token: 'old-token', 834 did: 'did:plc:test123', 835 sessionId: 'session-123', 836 ); 837 when( 838 mockStorage.read(key: storageKey), 839 ).thenAnswer((_) async => initialSession.toJsonString()); 840 await authService.restoreSession(); 841 842 const newToken1 = 'new-token-1'; 843 const newToken2 = 'new-token-2'; 844 845 // Mock first refresh 846 when( 847 mockDio.post<Map<String, dynamic>>( 848 '/oauth/refresh', 849 data: anyNamed('data'), 850 ), 851 ).thenAnswer( 852 (_) async => Response( 853 requestOptions: RequestOptions(path: '/oauth/refresh'), 854 statusCode: 200, 855 data: { 856 'sealed_token': newToken1, 857 'access_token': 'some-access-token', 858 }, 859 ), 860 ); 861 862 when( 863 mockStorage.write(key: storageKey, value: anyNamed('value')), 864 ).thenAnswer((_) async => {}); 865 866 // Act - First refresh 867 final result1 = await authService.refreshToken(); 868 869 // Assert first refresh 870 expect(result1.token, newToken1); 871 872 // Now update the mock for the second refresh 873 when( 874 mockDio.post<Map<String, dynamic>>( 875 '/oauth/refresh', 876 data: anyNamed('data'), 877 ), 878 ).thenAnswer( 879 (_) async => Response( 880 requestOptions: RequestOptions(path: '/oauth/refresh'), 881 statusCode: 200, 882 data: { 883 'sealed_token': newToken2, 884 'access_token': 'some-access-token', 885 }, 886 ), 887 ); 888 889 // Act - Second refresh (should be allowed since first completed) 890 final result2 = await authService.refreshToken(); 891 892 // Assert second refresh 893 expect(result2.token, newToken2); 894 895 // Verify two separate API calls were made 896 verify( 897 mockDio.post<Map<String, dynamic>>( 898 '/oauth/refresh', 899 data: anyNamed('data'), 900 ), 901 ).called(2); 902 }); 903 904 test('should allow new refresh after previous one fails', () async { 905 // Arrange - First restore a session 906 const initialSession = CovesSession( 907 token: 'old-token', 908 did: 'did:plc:test123', 909 sessionId: 'session-123', 910 ); 911 when( 912 mockStorage.read(key: storageKey), 913 ).thenAnswer((_) async => initialSession.toJsonString()); 914 await authService.restoreSession(); 915 916 // Mock first refresh to fail 917 when( 918 mockDio.post<Map<String, dynamic>>( 919 '/oauth/refresh', 920 data: anyNamed('data'), 921 ), 922 ).thenThrow( 923 DioException( 924 requestOptions: RequestOptions(path: '/oauth/refresh'), 925 type: DioExceptionType.connectionError, 926 message: 'Connection failed', 927 ), 928 ); 929 930 // Act - First refresh should fail 931 Object? caughtError; 932 try { 933 await authService.refreshToken(); 934 fail('Expected exception to be thrown'); 935 } catch (e) { 936 caughtError = e; 937 } 938 939 // Assert first refresh failed with correct error 940 expect(caughtError, isNotNull); 941 expect(caughtError, isA<Exception>()); 942 expect(caughtError.toString(), contains('Token refresh failed')); 943 944 // Now mock a successful second refresh 945 const newToken = 'new-token-after-retry'; 946 when( 947 mockDio.post<Map<String, dynamic>>( 948 '/oauth/refresh', 949 data: anyNamed('data'), 950 ), 951 ).thenAnswer( 952 (_) async => Response( 953 requestOptions: RequestOptions(path: '/oauth/refresh'), 954 statusCode: 200, 955 data: { 956 'sealed_token': newToken, 957 'access_token': 'some-access-token', 958 }, 959 ), 960 ); 961 962 when( 963 mockStorage.write(key: storageKey, value: anyNamed('value')), 964 ).thenAnswer((_) async => {}); 965 966 // Act - Second refresh (should be allowed and succeed) 967 final result = await authService.refreshToken(); 968 969 // Assert 970 expect(result.token, newToken); 971 972 // Verify two separate API calls were made 973 verify( 974 mockDio.post<Map<String, dynamic>>( 975 '/oauth/refresh', 976 data: anyNamed('data'), 977 ), 978 ).called(2); 979 }); 980 981 test( 982 'should handle concurrent calls where one arrives after refresh completes', 983 () async { 984 // Arrange - First restore a session 985 const initialSession = CovesSession( 986 token: 'old-token', 987 did: 'did:plc:test123', 988 sessionId: 'session-123', 989 ); 990 when( 991 mockStorage.read(key: storageKey), 992 ).thenAnswer((_) async => initialSession.toJsonString()); 993 await authService.restoreSession(); 994 995 const newToken1 = 'new-token-1'; 996 const newToken2 = 'new-token-2'; 997 998 var callCount = 0; 999 1000 // Mock refresh with different responses 1001 when( 1002 mockDio.post<Map<String, dynamic>>( 1003 '/oauth/refresh', 1004 data: anyNamed('data'), 1005 ), 1006 ).thenAnswer((_) async { 1007 callCount++; 1008 await Future.delayed(const Duration(milliseconds: 50)); 1009 return Response( 1010 requestOptions: RequestOptions(path: '/oauth/refresh'), 1011 statusCode: 200, 1012 data: { 1013 'sealed_token': callCount == 1 ? newToken1 : newToken2, 1014 'access_token': 'some-access-token', 1015 }, 1016 ); 1017 }); 1018 1019 when( 1020 mockStorage.write(key: storageKey, value: anyNamed('value')), 1021 ).thenAnswer((_) async => {}); 1022 1023 // Act - Start first refresh 1024 final future1 = authService.refreshToken(); 1025 1026 // Wait for it to complete 1027 final result1 = await future1; 1028 1029 // Start second refresh after first completes 1030 final result2 = await authService.refreshToken(); 1031 1032 // Assert 1033 expect(result1.token, newToken1); 1034 expect(result2.token, newToken2); 1035 1036 // Verify two separate API calls were made 1037 verify( 1038 mockDio.post<Map<String, dynamic>>( 1039 '/oauth/refresh', 1040 data: anyNamed('data'), 1041 ), 1042 ).called(2); 1043 }, 1044 ); 1045 }); 1046 }); 1047}