Main coves client
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}