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(() => 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}