···
group('CovesAuthService', () {
test('should throw ArgumentError when handle is empty', () async {
-
() => authService.signIn(''),
-
throwsA(isA<ArgumentError>()),
-
test('should throw ArgumentError when handle is whitespace-only',
-
() => authService.signIn(' '),
-
throwsA(isA<ArgumentError>()),
-
test('should throw appropriate error when user cancels sign-in',
// Note: FlutterWebAuth2.authenticate is not easily mockable as it's a static method
// This test documents expected behavior when authentication is cancelled
// In practice, this would throw with CANCELED/cancelled in the message
···
// Skipping for now as it requires more complex mocking infrastructure
-
test('should throw Exception when network error occurs during OAuth',
-
// Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock
-
// This test documents expected behavior
-
// The actual implementation catches exceptions and rethrows with context
test('should trim handle before processing', () async {
// This test verifies the handle trimming logic
···
final jsonString = session.toJsonString();
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => jsonString);
final result = await authService.restoreSession();
···
test('should return null when no stored session exists', () async {
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => null);
final result = await authService.restoreSession();
···
test('should handle corrupted storage data gracefully', () async {
const corruptedJson = 'not-valid-json{]';
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => corruptedJson);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
final result = await authService.restoreSession();
···
verify(mockStorage.delete(key: storageKey)).called(1);
-
test('should handle session JSON with missing required fields gracefully',
-
const invalidJson = '{"token": "test"}'; // Missing required fields (did, session_id)
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => invalidJson);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
-
final result = await authService.restoreSession();
-
// Should return null and clear corrupted storage
-
expect(result, isNull);
-
verify(mockStorage.read(key: storageKey)).called(1);
-
verify(mockStorage.delete(key: storageKey)).called(1);
test('should handle storage read errors gracefully', () async {
-
when(mockStorage.read(key: storageKey))
-
.thenThrow(Exception('Storage error'));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
final result = await authService.restoreSession();
···
group('refreshToken()', () {
test('should throw StateError when no session exists', () async {
-
() => authService.refreshToken(),
-
throwsA(isA<StateError>()),
-
test('should successfully refresh token and return updated session',
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
handle: 'alice.bsky.social',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
-
// Mock successful refresh response
-
const newToken = 'new-refreshed-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
-
final result = await authService.refreshToken();
-
expect(result.token, newToken);
-
expect(result.did, 'did:plc:test123');
-
expect(result.sessionId, 'session-123');
-
expect(result.handle, 'alice.bsky.social');
-
verify(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
verify(mockStorage.write(
-
value: anyNamed('value'),
test('should throw "Session expired" on 401 response', () async {
// Arrange - First restore a session
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.badResponse,
···
() => authService.refreshToken(),
-
e is Exception && e.toString().contains('Session expired')),
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
() => authService.refreshToken(),
-
e.toString().contains('Token refresh failed')),
-
test('should throw Exception when response is missing sealed_token', () async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
// Mock response without sealed_token
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
data: {'access_token': 'some-token'}, // No sealed_token field
-
() => authService.refreshToken(),
-
e.toString().contains('Invalid refresh response')),
-
test('should throw Exception when response sealed_token is empty', () async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
// Mock response with empty sealed_token
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': '', 'access_token': 'some-token'}, // Empty sealed_token
-
() => authService.refreshToken(),
-
e.toString().contains('Invalid refresh response')),
-
test('should clear session and storage on successful server-side logout',
-
// Arrange - First restore a session
-
const session = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
// Mock successful logout
-
when(mockDio.post<void>(
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: '/oauth/logout'),
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
-
await authService.signOut();
-
expect(authService.session, isNull);
-
expect(authService.isAuthenticated, isFalse);
-
verify(mockDio.post<void>(
-
options: anyNamed('options'),
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
test('should clear local state even when server revocation fails',
-
// Arrange - First restore a session
-
const session = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
when(mockDio.post<void>(
-
options: anyNamed('options'),
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
type: DioExceptionType.connectionError,
-
message: 'Connection failed',
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
-
await authService.signOut();
-
expect(authService.session, isNull);
-
expect(authService.isAuthenticated, isFalse);
-
verify(mockStorage.delete(key: storageKey)).called(1);
test('should work even when no session exists', () async {
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
await authService.signOut();
···
expect(authService.session, isNull);
expect(authService.isAuthenticated, isFalse);
verify(mockStorage.delete(key: storageKey)).called(1);
-
verifyNever(mockDio.post<void>(
-
options: anyNamed('options'),
test('should clear local state even when storage delete fails', () async {
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
when(mockStorage.delete(key: storageKey))
-
.thenThrow(Exception('Storage error'));
// Act & Assert - Should not throw
expect(() => authService.signOut(), throwsA(isA<Exception>()));
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
await authService.signOut();
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
await authService.signOut();
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken = 'new-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
await authService.refreshToken();
···
group('refreshToken() - Concurrency Protection', () {
-
test('should only make one API request for concurrent refresh calls',
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
handle: 'alice.bsky.social',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
-
const newToken = 'new-refreshed-token';
-
// Mock refresh response with a delay to simulate network latency
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
-
// Act - Launch 3 concurrent refresh calls
-
final results = await Future.wait([
-
authService.refreshToken(),
-
authService.refreshToken(),
-
authService.refreshToken(),
-
// Assert - All calls should return the same refreshed session
-
expect(results.length, 3);
-
expect(results[0].token, newToken);
-
expect(results[1].token, newToken);
-
expect(results[2].token, newToken);
-
// Verify only one API call was made
-
verify(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
// Verify only one storage write was made
-
verify(mockStorage.write(
-
value: anyNamed('value'),
test('should propagate errors to all concurrent waiters', () async {
// Arrange - First restore a session
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock 401 response with delay
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
requestOptions: RequestOptions(path: '/oauth/refresh'),
···
// Verify only one API call was made
-
verify(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
test('should allow new refresh after previous one completes', () async {
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken1 = 'new-token-1';
const newToken2 = 'new-token-2';
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': newToken1, 'access_token': 'some-access-token'},
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
final result1 = await authService.refreshToken();
···
expect(result1.token, newToken1);
// Now update the mock for the second refresh
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': newToken2, 'access_token': 'some-access-token'},
// Act - Second refresh (should be allowed since first completed)
final result2 = await authService.refreshToken();
···
expect(result2.token, newToken2);
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
test('should allow new refresh after previous one fails', () async {
···
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
// Mock first refresh to fail
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
// Now mock a successful second refresh
const newToken = 'new-token-after-retry';
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
// Act - Second refresh (should be allowed and succeed)
final result = await authService.refreshToken();
···
expect(result.token, newToken);
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
'should handle concurrent calls where one arrives after refresh completes',
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
-
const newToken1 = 'new-token-1';
-
const newToken2 = 'new-token-2';
-
// Mock refresh with different responses
-
when(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 50));
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
data: {'sealed_token': callCount == 1 ? newToken1 : newToken2, 'access_token': 'some-access-token'},
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
-
// Act - Start first refresh
-
final future1 = authService.refreshToken();
-
// Wait for it to complete
-
final result1 = await future1;
-
// Start second refresh after first completes
-
final result2 = await authService.refreshToken();
-
expect(result1.token, newToken1);
-
expect(result2.token, newToken2);
-
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
data: anyNamed('data'),
···
group('CovesAuthService', () {
test('should throw ArgumentError when handle is empty', () async {
+
expect(() => authService.signIn(''), throwsA(isA<ArgumentError>()));
+
'should throw ArgumentError when handle is whitespace-only',
+
() => authService.signIn(' '),
+
throwsA(isA<ArgumentError>()),
+
test('should throw appropriate error when user cancels sign-in', () async {
// Note: FlutterWebAuth2.authenticate is not easily mockable as it's a static method
// This test documents expected behavior when authentication is cancelled
// In practice, this would throw with CANCELED/cancelled in the message
···
// Skipping for now as it requires more complex mocking infrastructure
+
'should throw Exception when network error occurs during OAuth',
+
// Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock
+
// This test documents expected behavior
+
// The actual implementation catches exceptions and rethrows with context
test('should trim handle before processing', () async {
// This test verifies the handle trimming logic
···
final jsonString = session.toJsonString();
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => jsonString);
final result = await authService.restoreSession();
···
test('should return null when no stored session exists', () async {
+
when(mockStorage.read(key: storageKey)).thenAnswer((_) async => null);
final result = await authService.restoreSession();
···
test('should handle corrupted storage data gracefully', () async {
const corruptedJson = 'not-valid-json{]';
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => corruptedJson);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
final result = await authService.restoreSession();
···
verify(mockStorage.delete(key: storageKey)).called(1);
+
'should handle session JSON with missing required fields gracefully',
+
'{"token": "test"}'; // Missing required fields (did, session_id)
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => invalidJson);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
+
final result = await authService.restoreSession();
+
// Should return null and clear corrupted storage
+
expect(result, isNull);
+
verify(mockStorage.read(key: storageKey)).called(1);
+
verify(mockStorage.delete(key: storageKey)).called(1);
test('should handle storage read errors gracefully', () async {
+
mockStorage.read(key: storageKey),
+
).thenThrow(Exception('Storage error'));
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
final result = await authService.restoreSession();
···
group('refreshToken()', () {
test('should throw StateError when no session exists', () async {
+
expect(() => authService.refreshToken(), throwsA(isA<StateError>()));
+
'should successfully refresh token and return updated session',
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
handle: 'alice.bsky.social',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
+
await authService.restoreSession();
+
// Mock successful refresh response
+
const newToken = 'new-refreshed-token';
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
+
final result = await authService.refreshToken();
+
expect(result.token, newToken);
+
expect(result.did, 'did:plc:test123');
+
expect(result.sessionId, 'session-123');
+
expect(result.handle, 'alice.bsky.social');
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
test('should throw "Session expired" on 401 response', () async {
// Arrange - First restore a session
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.badResponse,
···
() => authService.refreshToken(),
+
(e) => e is Exception && e.toString().contains('Session expired'),
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
() => authService.refreshToken(),
+
e.toString().contains('Token refresh failed'),
+
'should throw Exception when response is missing sealed_token',
+
// Arrange - First restore a session
+
const session = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
// Mock response without sealed_token
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
data: {'access_token': 'some-token'}, // No sealed_token field
+
() => authService.refreshToken(),
+
e.toString().contains('Invalid refresh response'),
+
'should throw Exception when response sealed_token is empty',
+
// Arrange - First restore a session
+
const session = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
// Mock response with empty sealed_token
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'access_token': 'some-token',
+
}, // Empty sealed_token
+
() => authService.refreshToken(),
+
e.toString().contains('Invalid refresh response'),
+
'should clear session and storage on successful server-side logout',
+
// Arrange - First restore a session
+
const session = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
// Mock successful logout
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
requestOptions: RequestOptions(path: '/oauth/logout'),
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
+
await authService.signOut();
+
expect(authService.session, isNull);
+
expect(authService.isAuthenticated, isFalse);
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
verify(mockStorage.delete(key: storageKey)).called(1);
+
'should clear local state even when server revocation fails',
+
// Arrange - First restore a session
+
const session = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
type: DioExceptionType.connectionError,
+
message: 'Connection failed',
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
+
await authService.signOut();
+
expect(authService.session, isNull);
+
expect(authService.isAuthenticated, isFalse);
+
verify(mockStorage.delete(key: storageKey)).called(1);
test('should work even when no session exists', () async {
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
await authService.signOut();
···
expect(authService.session, isNull);
expect(authService.isAuthenticated, isFalse);
verify(mockStorage.delete(key: storageKey)).called(1);
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
test('should clear local state even when storage delete fails', () async {
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
mockStorage.delete(key: storageKey),
+
).thenThrow(Exception('Storage error'));
// Act & Assert - Should not throw
expect(() => authService.signOut(), throwsA(isA<Exception>()));
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
await authService.signOut();
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
await authService.signOut();
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken = 'new-token';
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
await authService.refreshToken();
···
group('refreshToken() - Concurrency Protection', () {
+
'should only make one API request for concurrent refresh calls',
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
handle: 'alice.bsky.social',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
+
await authService.restoreSession();
+
const newToken = 'new-refreshed-token';
+
// Mock refresh response with a delay to simulate network latency
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
).thenAnswer((_) async {
+
await Future.delayed(const Duration(milliseconds: 100));
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
+
// Act - Launch 3 concurrent refresh calls
+
final results = await Future.wait([
+
authService.refreshToken(),
+
authService.refreshToken(),
+
authService.refreshToken(),
+
// Assert - All calls should return the same refreshed session
+
expect(results.length, 3);
+
expect(results[0].token, newToken);
+
expect(results[1].token, newToken);
+
expect(results[2].token, newToken);
+
// Verify only one API call was made
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
// Verify only one storage write was made
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
test('should propagate errors to all concurrent waiters', () async {
// Arrange - First restore a session
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock 401 response with delay
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
requestOptions: RequestOptions(path: '/oauth/refresh'),
···
// Verify only one API call was made
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
test('should allow new refresh after previous one completes', () async {
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken1 = 'new-token-1';
const newToken2 = 'new-token-2';
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': newToken1,
+
'access_token': 'some-access-token',
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
final result1 = await authService.refreshToken();
···
expect(result1.token, newToken1);
// Now update the mock for the second refresh
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': newToken2,
+
'access_token': 'some-access-token',
// Act - Second refresh (should be allowed since first completed)
final result2 = await authService.refreshToken();
···
expect(result2.token, newToken2);
// Verify two separate API calls were made
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
test('should allow new refresh after previous one fails', () async {
···
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
// Mock first refresh to fail
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
// Now mock a successful second refresh
const newToken = 'new-token-after-retry';
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - Second refresh (should be allowed and succeed)
final result = await authService.refreshToken();
···
expect(result.token, newToken);
// Verify two separate API calls were made
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
'should handle concurrent calls where one arrives after refresh completes',
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
+
await authService.restoreSession();
+
const newToken1 = 'new-token-1';
+
const newToken2 = 'new-token-2';
+
// Mock refresh with different responses
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),
+
).thenAnswer((_) async {
+
await Future.delayed(const Duration(milliseconds: 50));
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
'sealed_token': callCount == 1 ? newToken1 : newToken2,
+
'access_token': 'some-access-token',
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
+
// Act - Start first refresh
+
final future1 = authService.refreshToken();
+
// Wait for it to complete
+
final result1 = await future1;
+
// Start second refresh after first completes
+
final result2 = await authService.refreshToken();
+
expect(result1.token, newToken1);
+
expect(result2.token, newToken2);
+
// Verify two separate API calls were made
+
mockDio.post<Map<String, dynamic>>(
+
data: anyNamed('data'),