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