style: apply dart format to all files

Auto-format all Dart files per CODE_QUALITY_GUIDE.md standards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+9 -10
lib/services/coves_api_service.dart
···
Future<bool> Function()? tokenRefresher,
Future<void> Function()? signOutHandler,
Dio? dio,
-
}) : _tokenGetter = tokenGetter,
-
_tokenRefresher = tokenRefresher,
-
_signOutHandler = signOutHandler {
+
}) : _tokenGetter = tokenGetter,
+
_tokenRefresher = tokenRefresher,
+
_signOutHandler = signOutHandler {
_dio =
dio ??
Dio(
···
}
// Don't retry the refresh endpoint itself (avoid infinite loop)
-
final isRefreshEndpoint =
-
error.requestOptions.path.contains('/oauth/refresh');
+
final isRefreshEndpoint = error.requestOptions.path.contains(
+
'/oauth/refresh',
+
);
if (isRefreshEndpoint) {
if (kDebugMode) {
debugPrint(
···
}
// Only sign out if we haven't already (avoid double sign-out)
// Check if this is a DioException from a retried request
-
final isRetriedRequest = e is DioException &&
+
final isRetriedRequest =
+
e is DioException &&
e.response?.statusCode == 401 &&
e.requestOptions.extra['retried'] == true;
···
if (kDebugMode) {
debugPrint('❌ Error parsing comments response: $e');
}
-
throw ApiException(
-
'Failed to parse server response',
-
originalError: e,
-
);
+
throw ApiException('Failed to parse server response', originalError: e);
}
}
+12 -21
lib/services/coves_auth_service.dart
···
/// 4. Call /oauth/refresh when needed
/// 5. Call /oauth/logout to sign out
class CovesAuthService {
-
factory CovesAuthService({
-
Dio? dio,
-
FlutterSecureStorage? storage,
-
}) {
+
factory CovesAuthService({Dio? dio, FlutterSecureStorage? storage}) {
_instance ??= CovesAuthService._internal(dio: dio, storage: storage);
return _instance!;
}
-
CovesAuthService._internal({
-
Dio? dio,
-
FlutterSecureStorage? storage,
-
}) : _storage = storage ??
-
const FlutterSecureStorage(
-
aOptions: AndroidOptions(encryptedSharedPreferences: true),
-
iOptions: IOSOptions(
-
accessibility: KeychainAccessibility.first_unlock,
-
),
-
) {
+
CovesAuthService._internal({Dio? dio, FlutterSecureStorage? storage})
+
: _storage =
+
storage ??
+
const FlutterSecureStorage(
+
aOptions: AndroidOptions(encryptedSharedPreferences: true),
+
iOptions: IOSOptions(
+
accessibility: KeychainAccessibility.first_unlock,
+
),
+
) {
// Initialize Dio if provided, otherwise it will be initialized in initialize()
if (dio != null) {
_dio = dio;
···
await _dio.post<void>(
'/oauth/logout',
options: Options(
-
headers: {
-
'Authorization': 'Bearer ${_session!.token}',
-
},
+
headers: {'Authorization': 'Bearer ${_session!.token}'},
),
);
···
/// Save session to secure storage
Future<void> _saveSession(CovesSession session) async {
-
await _storage.write(
-
key: _storageKey,
-
value: session.toJsonString(),
-
);
+
await _storage.write(key: _storageKey, value: session.toJsonString());
}
/// Clear session from secure storage
+16 -16
lib/services/vote_service.dart
···
_didGetter = didGetter,
_tokenRefresher = tokenRefresher,
_signOutHandler = signOutHandler {
-
_dio = dio ??
+
_dio =
+
dio ??
Dio(
BaseOptions(
baseUrl: EnvironmentConfig.current.apiUrl,
···
// Handle 401 errors with automatic token refresh
if (error.response?.statusCode == 401 && _tokenRefresher != null) {
if (kDebugMode) {
-
debugPrint('🔄 VoteService: 401 detected, attempting token refresh...');
+
debugPrint(
+
'🔄 VoteService: 401 detected, attempting token refresh...',
+
);
}
// Check if we already retried this request (prevent infinite loop)
···
if (refreshSucceeded) {
if (kDebugMode) {
-
debugPrint('✅ VoteService: Token refresh successful, retrying request');
+
debugPrint(
+
'✅ VoteService: Token refresh successful, retrying request',
+
);
}
// Get the new session
···
// Refresh failed, sign out the user
if (kDebugMode) {
-
debugPrint('❌ VoteService: Token refresh failed, signing out user');
+
debugPrint(
+
'❌ VoteService: Token refresh failed, signing out user',
+
);
}
if (_signOutHandler != null) {
await _signOutHandler();
···
}
// Only sign out if we haven't already (avoid double sign-out)
// Check if this is a DioException from a retried request
-
final isRetriedRequest = e is DioException &&
+
final isRetriedRequest =
+
e is DioException &&
e.response?.statusCode == 401 &&
e.requestOptions.extra['retried'] == true;
···
if (isToggleOff) {
// Delete existing vote
-
return _deleteVote(
-
session: session,
-
rkey: existingVoteRkey,
-
);
+
return _deleteVote(session: session, rkey: existingVoteRkey);
}
// If switching direction, delete old vote first
···
final response = await _dio.post<Map<String, dynamic>>(
'/xrpc/social.coves.feed.vote.create',
data: {
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
-
},
+
'subject': {'uri': postUri, 'cid': postCid},
'direction': direction,
},
);
···
// Note: Authorization header is added by the interceptor
await _dio.post<void>(
'/xrpc/social.coves.feed.vote.delete',
-
data: {
-
'rkey': rkey,
-
},
+
data: {'rkey': rkey},
);
if (kDebugMode) {
-1
lib/widgets/comment_card.dart
···
/// Custom painter for drawing comment depth indicator lines
class _CommentDepthPainter extends CustomPainter {
-
_CommentDepthPainter({required this.depth});
final int depth;
-1
lib/widgets/icons/bluesky_icons.dart
···
/// Bluesky-style navigation icons using SVG assets
/// These icons match the design from Bluesky's social-app
class BlueSkyIcon extends StatelessWidget {
-
const BlueSkyIcon({
required this.iconName,
this.size = 28,
+1 -3
lib/widgets/post_action_bar.dart
···
return Container(
decoration: const BoxDecoration(
color: AppColors.background,
-
border: Border(
-
top: BorderSide(color: AppColors.backgroundSecondary),
-
),
+
border: Border(top: BorderSide(color: AppColors.backgroundSecondary)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SafeArea(
+15 -18
lib/widgets/post_card.dart
···
),
)
else
-
// Title when navigation is disabled
-
if (post.post.title != null) ...[
-
Text(
-
post.post.title!,
-
style: TextStyle(
-
color: AppColors.textPrimary,
-
fontSize: titleFontSize,
-
fontWeight: titleFontWeight,
-
height: 1.3,
-
),
+
// Title when navigation is disabled
+
if (post.post.title != null) ...[
+
Text(
+
post.post.title!,
+
style: TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: titleFontSize,
+
fontWeight: titleFontWeight,
+
height: 1.3,
),
-
if (post.post.embed?.external != null ||
-
post.post.text.isNotEmpty)
-
const SizedBox(height: 8),
-
],
+
),
+
if (post.post.embed?.external != null ||
+
post.post.text.isNotEmpty)
+
const SizedBox(height: 8),
+
],
// Embed (handles its own taps - not wrapped in InkWell)
if (post.post.embed?.external != null) ...[
···
// For non-video embeds (images, link previews), make them tappable
// to navigate to post detail
if (widget.onImageTap != null) {
-
return GestureDetector(
-
onTap: widget.onImageTap,
-
child: thumbnailWidget,
-
);
+
return GestureDetector(onTap: widget.onImageTap, child: thumbnailWidget);
}
// No tap handler provided, just return the thumbnail
+6 -8
lib/widgets/post_card_actions.dart
···
Icon(
Icons.chat_bubble_outline,
size: 20,
-
color:
-
AppColors.textPrimary.withValues(
-
alpha: 0.6,
-
),
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.6,
+
),
),
const SizedBox(width: 5),
Text(
DateTimeUtils.formatCount(count),
style: TextStyle(
-
color:
-
AppColors.textPrimary.withValues(
-
alpha: 0.6,
-
),
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.6,
+
),
fontSize: 13,
),
),
+13 -37
test/models/coves_session_test.dart
···
'session_id': 'sess456',
};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when did has wrong type', () {
···
'session_id': 'sess456',
};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when session_id has wrong type', () {
···
'session_id': 123, // Should be String
};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when token field is missing', () {
-
final json = {
-
'did': 'did:plc:test123',
-
'session_id': 'sess456',
-
};
+
final json = {'did': 'did:plc:test123', 'session_id': 'sess456'};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when did field is missing', () {
-
final json = {
-
'token': 'abc123',
-
'session_id': 'sess456',
-
};
+
final json = {'token': 'abc123', 'session_id': 'sess456'};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when session_id field is missing', () {
-
final json = {
-
'token': 'abc123',
-
'did': 'did:plc:test123',
-
};
+
final json = {'token': 'abc123', 'did': 'did:plc:test123'};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should handle extra fields in JSON', () {
···
final stringRep = session.toString();
expect(stringRep, isNot(contains('Bearer')));
-
expect(stringRep, isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')));
+
expect(
+
stringRep,
+
isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')),
+
);
});
});
}
+3 -2
test/providers/auth_provider_test.dart
···
await authProvider.signIn('alice.bsky.social');
// Sign out with error
-
when(mockAuthService.signOut())
-
.thenThrow(Exception('Revocation failed'));
+
when(
+
mockAuthService.signOut(),
+
).thenThrow(Exception('Revocation failed'));
await authProvider.signOut();
+4 -15
test/providers/comments_provider_test.dart
···
});
test('should handle empty comments response', () async {
-
final mockResponse = CommentsResponse(
-
post: {},
-
comments: [],
-
);
+
final mockResponse = CommentsResponse(post: {}, comments: []);
when(
mockApiService.getComments(
···
),
).thenAnswer((_) async => secondResponse);
-
await commentsProvider.loadComments(
-
postUri: testPostUri,
-
);
+
await commentsProvider.loadComments(postUri: testPostUri);
expect(commentsProvider.comments.length, 2);
expect(commentsProvider.comments[0].comment.uri, 'comment1');
···
test('should load vote state when authenticated', () async {
final mockComments = [_createMockThreadComment('comment1')];
-
final mockResponse = CommentsResponse(
-
post: {},
-
comments: mockComments,
-
);
+
final mockResponse = CommentsResponse(post: {}, comments: mockComments);
when(
mockApiService.getComments(
···
test('should continue loading comments if vote loading fails', () async {
final mockComments = [_createMockThreadComment('comment1')];
-
final mockResponse = CommentsResponse(
-
post: {},
-
comments: mockComments,
-
);
+
final mockResponse = CommentsResponse(post: {}, comments: mockComments);
when(
mockApiService.getComments(
+81 -74
test/services/coves_api_service_token_refresh_test.dart
···
apiService.dispose();
});
-
test('should call token refresher on 401 response but only retry once',
-
() async {
+
test('should call token refresher on 401 response but only retry once', () async {
// This test verifies the interceptor detects 401, calls the refresher,
// and only retries ONCE to prevent infinite loops (even if retry returns 401).
···
expect(signOutCallCount, 1);
});
-
test('should NOT retry refresh endpoint on 401 (avoid infinite loop)',
-
() async {
-
// This test verifies that the interceptor checks for /oauth/refresh
-
// in the path to avoid infinite loops. Due to limitations with mocking
-
// complex request/response cycles, we test this by verifying the
-
// signOutHandler gets called when refresh fails.
+
test(
+
'should NOT retry refresh endpoint on 401 (avoid infinite loop)',
+
() async {
+
// This test verifies that the interceptor checks for /oauth/refresh
+
// in the path to avoid infinite loops. Due to limitations with mocking
+
// complex request/response cycles, we test this by verifying the
+
// signOutHandler gets called when refresh fails.
-
// Set refresh to fail (simulates refresh endpoint returning 401)
-
shouldRefreshSucceed = false;
+
// Set refresh to fail (simulates refresh endpoint returning 401)
+
shouldRefreshSucceed = false;
-
const postUri = 'at://did:plc:test/social.coves.post.record/123';
+
const postUri = 'at://did:plc:test/social.coves.post.record/123';
-
dioAdapter.onGet(
-
'/xrpc/social.coves.community.comment.getComments',
-
(server) => server.reply(401, {
-
'error': 'Unauthorized',
-
'message': 'Token expired',
-
}),
-
queryParameters: {
-
'post': postUri,
-
'sort': 'hot',
-
'depth': 10,
-
'limit': 50,
-
},
-
);
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.comment.getComments',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Token expired',
+
}),
+
queryParameters: {
+
'post': postUri,
+
'sort': 'hot',
+
'depth': 10,
+
'limit': 50,
+
},
+
);
-
// Make the request and expect it to fail
-
expect(
-
() => apiService.getComments(postUri: postUri),
-
throwsA(isA<Exception>()),
-
);
+
// Make the request and expect it to fail
+
expect(
+
() => apiService.getComments(postUri: postUri),
+
throwsA(isA<Exception>()),
+
);
-
// Wait for async operations to complete
-
await Future.delayed(const Duration(milliseconds: 100));
+
// Wait for async operations to complete
+
await Future.delayed(const Duration(milliseconds: 100));
-
// Verify user was signed out (no infinite loop)
-
expect(signOutCallCount, 1);
-
});
+
// Verify user was signed out (no infinite loop)
+
expect(signOutCallCount, 1);
+
},
+
);
-
test('should sign out user if token refresh throws exception', () async {
-
// Skipped: causes retry loops with http_mock_adapter after disposal
-
// The core functionality is tested by the "should sign out user if token
-
// refresh fails" test above.
-
}, skip: 'Causes retry issues with http_mock_adapter');
+
test(
+
'should sign out user if token refresh throws exception',
+
() async {
+
// Skipped: causes retry loops with http_mock_adapter after disposal
+
// The core functionality is tested by the "should sign out user if token
+
// refresh fails" test above.
+
},
+
skip: 'Causes retry issues with http_mock_adapter',
+
);
-
test('should handle 401 gracefully when no refresher is provided',
-
() async {
-
// Create API service without refresh capability
-
final apiServiceNoRefresh = CovesApiService(
-
dio: dio,
-
tokenGetter: mockTokenGetter,
-
// No tokenRefresher provided
-
// No signOutHandler provided
-
);
+
test(
+
'should handle 401 gracefully when no refresher is provided',
+
() async {
+
// Create API service without refresh capability
+
final apiServiceNoRefresh = CovesApiService(
+
dio: dio,
+
tokenGetter: mockTokenGetter,
+
// No tokenRefresher provided
+
// No signOutHandler provided
+
);
-
const postUri = 'at://did:plc:test/social.coves.post.record/123';
+
const postUri = 'at://did:plc:test/social.coves.post.record/123';
-
// Request returns 401
-
dioAdapter.onGet(
-
'/xrpc/social.coves.community.comment.getComments',
-
(server) => server.reply(401, {
-
'error': 'Unauthorized',
-
'message': 'Token expired',
-
}),
-
queryParameters: {
-
'post': postUri,
-
'sort': 'hot',
-
'depth': 10,
-
'limit': 50,
-
},
-
);
+
// Request returns 401
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.comment.getComments',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Token expired',
+
}),
+
queryParameters: {
+
'post': postUri,
+
'sort': 'hot',
+
'depth': 10,
+
'limit': 50,
+
},
+
);
-
// Make the request and expect it to fail with AuthenticationException
-
expect(
-
() => apiServiceNoRefresh.getComments(postUri: postUri),
-
throwsA(isA<Exception>()),
-
);
+
// Make the request and expect it to fail with AuthenticationException
+
expect(
+
() => apiServiceNoRefresh.getComments(postUri: postUri),
+
throwsA(isA<Exception>()),
+
);
-
// Verify refresh was NOT called (no refresher provided)
-
expect(tokenRefreshCallCount, 0);
+
// Verify refresh was NOT called (no refresher provided)
+
expect(tokenRefreshCallCount, 0);
-
// Verify sign-out was NOT called (no handler provided)
-
expect(signOutCallCount, 0);
+
// Verify sign-out was NOT called (no handler provided)
+
expect(signOutCallCount, 0);
-
apiServiceNoRefresh.dispose();
-
});
+
apiServiceNoRefresh.dispose();
+
},
+
);
// Skipped: http_mock_adapter cannot handle stateful request/response cycles
+38 -29
test/services/coves_auth_service_environment_test.dart
···
);
// Mock storage read for the environment-specific key
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
// Act - Restore session
final result = await authService.restoreSession();
···
handle: 'alice.bsky.social',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock successful refresh
const newToken = 'new-refreshed-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {
-
'sealed_token': newToken,
-
'access_token': 'some-access-token'
-
},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
+
),
+
);
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - Refresh token (which saves the updated session)
await authService.refreshToken();
// Assert - Verify environment-specific key was used for saving
-
verify(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.called(1);
+
verify(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).called(1);
// Verify the generic key was never used
-
verifyNever(mockStorage.write(key: 'coves_session', value: anyNamed('value')));
+
verifyNever(
+
mockStorage.write(key: 'coves_session', value: anyNamed('value')),
+
);
});
test('should delete sessions using environment-specific keys', () async {
···
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock logout
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
+37 -20
test/services/coves_auth_service_redaction_test.dart
···
);
});
-
test('should preserve non-sensitive parameters (DID, handle, session_id)',
-
() {
-
const testUrl =
-
'social.coves:/callback?token=sealed_token_abc123&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social';
+
test(
+
'should preserve non-sensitive parameters (DID, handle, session_id)',
+
() {
+
const testUrl =
+
'social.coves:/callback?token=sealed_token_abc123&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social';
-
final redacted = testUrl.replaceAllMapped(
-
RegExp(r'token=([^&\s]+)'),
-
(match) => 'token=[REDACTED]',
-
);
+
final redacted = testUrl.replaceAllMapped(
+
RegExp(r'token=([^&\s]+)'),
+
(match) => 'token=[REDACTED]',
+
);
-
expect(redacted, contains('did=did:plc:test123'));
-
expect(redacted, contains('session_id=sess-456'));
-
expect(redacted, contains('handle=alice.bsky.social'));
-
expect(redacted, isNot(contains('sealed_token_abc123')));
-
});
+
expect(redacted, contains('did=did:plc:test123'));
+
expect(redacted, contains('session_id=sess-456'));
+
expect(redacted, contains('handle=alice.bsky.social'));
+
expect(redacted, isNot(contains('sealed_token_abc123')));
+
},
+
);
test('should handle token as first parameter', () {
const testUrl =
···
(match) => 'token=[REDACTED]',
);
-
expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test');
+
expect(
+
redacted,
+
'social.coves:/callback?token=[REDACTED]&did=did:plc:test',
+
);
});
test('should handle token as last parameter', () {
-
const testUrl = 'social.coves:/callback?did=did:plc:test&token=last_token';
+
const testUrl =
+
'social.coves:/callback?did=did:plc:test&token=last_token';
final redacted = testUrl.replaceAllMapped(
RegExp(r'token=([^&\s]+)'),
(match) => 'token=[REDACTED]',
);
-
expect(redacted, 'social.coves:/callback?did=did:plc:test&token=[REDACTED]');
+
expect(
+
redacted,
+
'social.coves:/callback?did=did:plc:test&token=[REDACTED]',
+
);
});
test('should handle token as only parameter', () {
···
(match) => 'token=[REDACTED]',
);
-
expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test');
+
expect(
+
redacted,
+
'social.coves:/callback?token=[REDACTED]&did=did:plc:test',
+
);
expect(redacted, isNot(contains('encoded%2Btoken%3D123')));
});
test('should handle long token values', () {
const longToken =
'very_long_sealed_token_with_many_characters_1234567890abcdef';
-
final testUrl = 'social.coves:/callback?token=$longToken&did=did:plc:test';
+
final testUrl =
+
'social.coves:/callback?token=$longToken&did=did:plc:test';
final redacted = testUrl.replaceAllMapped(
RegExp(r'token=([^&\s]+)'),
(match) => 'token=[REDACTED]',
);
-
expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test');
+
expect(
+
redacted,
+
'social.coves:/callback?token=[REDACTED]&did=did:plc:test',
+
);
expect(redacted, isNot(contains(longToken)));
});
test('should handle URL without token parameter', () {
-
const testUrl = 'social.coves:/callback?did=did:plc:test&handle=alice.bsky.social';
+
const testUrl =
+
'social.coves:/callback?did=did:plc:test&handle=alice.bsky.social';
final redacted = testUrl.replaceAllMapped(
RegExp(r'token=([^&\s]+)'),
+87 -52
test/services/coves_auth_service_singleton_test.dart
···
final instance3 = CovesAuthService();
// Assert - All should be the exact same instance
-
expect(identical(instance1, instance2), isTrue,
-
reason: 'instance1 and instance2 should be identical');
-
expect(identical(instance2, instance3), isTrue,
-
reason: 'instance2 and instance3 should be identical');
-
expect(identical(instance1, instance3), isTrue,
-
reason: 'instance1 and instance3 should be identical');
+
expect(
+
identical(instance1, instance2),
+
isTrue,
+
reason: 'instance1 and instance2 should be identical',
+
);
+
expect(
+
identical(instance2, instance3),
+
isTrue,
+
reason: 'instance2 and instance3 should be identical',
+
);
+
expect(
+
identical(instance1, instance3),
+
isTrue,
+
reason: 'instance1 and instance3 should be identical',
+
);
});
test('should share in-memory session across singleton instances', () async {
···
final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage);
// Mock storage to return a valid session
-
const sessionJson = '{'
+
const sessionJson =
+
'{'
'"token": "test-token", '
'"did": "did:plc:test123", '
'"session_id": "session-123", '
'"handle": "alice.bsky.social"'
'}';
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => sessionJson);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => sessionJson);
// Act - Restore session using first instance
await instance1.restoreSession();
···
final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage);
// Mock storage to return a valid session
-
const sessionJson = '{'
+
const sessionJson =
+
'{'
'"token": "old-token", '
'"did": "did:plc:test123", '
'"session_id": "session-123", '
'"handle": "alice.bsky.social"'
'}';
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => sessionJson);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => sessionJson);
await instance1.restoreSession();
// Mock refresh with delay
const newToken = 'refreshed-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
return Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
···
);
});
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - Start refresh from first instance
final refreshFuture1 = instance1.refreshToken();
···
expect(results[1].token, newToken);
// Verify only one API call was made (mutex protected)
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
});
test('resetInstance() should clear the singleton', () {
···
test('createTestInstance() should bypass singleton', () {
// Arrange
-
final singletonInstance = CovesAuthService(dio: mockDio, storage: mockStorage);
+
final singletonInstance = CovesAuthService(
+
dio: mockDio,
+
storage: mockStorage,
+
);
// Act - Create a test instance with different dependencies
final mockDio2 = MockDio();
···
);
// Assert - Test instance should be different from singleton
-
expect(identical(singletonInstance, testInstance), isFalse,
-
reason: 'Test instance should not be the singleton');
+
expect(
+
identical(singletonInstance, testInstance),
+
isFalse,
+
reason: 'Test instance should not be the singleton',
+
);
// Test instance should not affect singleton
final singletonCheck = CovesAuthService();
-
expect(identical(singletonInstance, singletonCheck), isTrue,
-
reason: 'Singleton should remain unchanged');
+
expect(
+
identical(singletonInstance, singletonCheck),
+
isTrue,
+
reason: 'Singleton should remain unchanged',
+
);
});
-
test('should avoid state loss when service is requested from multiple entry points', () async {
-
// Arrange
-
final authProvider = CovesAuthService(dio: mockDio, storage: mockStorage);
+
test(
+
'should avoid state loss when service is requested from multiple entry points',
+
() async {
+
// Arrange
+
final authProvider = CovesAuthService(
+
dio: mockDio,
+
storage: mockStorage,
+
);
-
const sessionJson = '{'
-
'"token": "test-token", '
-
'"did": "did:plc:test123", '
-
'"session_id": "session-123"'
-
'}';
+
const sessionJson =
+
'{'
+
'"token": "test-token", '
+
'"did": "did:plc:test123", '
+
'"session_id": "session-123"'
+
'}';
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => sessionJson);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => sessionJson);
-
// Act - Simulate different parts of the app requesting the service
-
await authProvider.restoreSession();
+
// Act - Simulate different parts of the app requesting the service
+
await authProvider.restoreSession();
-
final apiService = CovesAuthService();
-
final voteService = CovesAuthService();
-
final feedService = CovesAuthService();
+
final apiService = CovesAuthService();
+
final voteService = CovesAuthService();
+
final feedService = CovesAuthService();
-
// Assert - All should have access to the same session state
-
expect(apiService.isAuthenticated, isTrue);
-
expect(voteService.isAuthenticated, isTrue);
-
expect(feedService.isAuthenticated, isTrue);
-
expect(apiService.getToken(), 'test-token');
-
expect(voteService.getToken(), 'test-token');
-
expect(feedService.getToken(), 'test-token');
+
// Assert - All should have access to the same session state
+
expect(apiService.isAuthenticated, isTrue);
+
expect(voteService.isAuthenticated, isTrue);
+
expect(feedService.isAuthenticated, isTrue);
+
expect(apiService.getToken(), 'test-token');
+
expect(voteService.getToken(), 'test-token');
+
expect(feedService.getToken(), 'test-token');
-
// Storage should only be read once
-
verify(mockStorage.read(key: storageKey)).called(1);
-
});
+
// Storage should only be read once
+
verify(mockStorage.read(key: storageKey)).called(1);
+
},
+
);
});
}
+542 -424
test/services/coves_auth_service_test.dart
···
group('CovesAuthService', () {
group('signIn()', () {
test('should throw ArgumentError when handle is empty', () async {
-
expect(
-
() => authService.signIn(''),
-
throwsA(isA<ArgumentError>()),
-
);
+
expect(() => authService.signIn(''), throwsA(isA<ArgumentError>()));
});
-
test('should throw ArgumentError when handle is whitespace-only',
-
() async {
-
expect(
-
() => authService.signIn(' '),
-
throwsA(isA<ArgumentError>()),
-
);
-
});
+
test(
+
'should throw ArgumentError when handle is whitespace-only',
+
() async {
+
expect(
+
() => authService.signIn(' '),
+
throwsA(isA<ArgumentError>()),
+
);
+
},
+
);
-
test('should throw appropriate error when user cancels sign-in',
-
() async {
+
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
});
-
test('should throw Exception when network error occurs during OAuth',
-
() async {
-
// 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 throw Exception when network error occurs during OAuth',
+
() async {
+
// 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);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => jsonString);
// Act
final result = await authService.restoreSession();
···
test('should return null when no stored session exists', () async {
// Arrange
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => null);
+
when(mockStorage.read(key: storageKey)).thenAnswer((_) async => null);
// Act
final result = await authService.restoreSession();
···
test('should handle corrupted storage data gracefully', () async {
// Arrange
const corruptedJson = 'not-valid-json{]';
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => corruptedJson);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => corruptedJson);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
final result = await authService.restoreSession();
···
verify(mockStorage.delete(key: storageKey)).called(1);
});
-
test('should handle session JSON with missing required fields gracefully',
-
() async {
-
// Arrange
-
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 => {});
+
test(
+
'should handle session JSON with missing required fields gracefully',
+
() async {
+
// Arrange
+
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 => {});
-
// Act
-
final result = await authService.restoreSession();
+
// Act
+
final result = await authService.restoreSession();
-
// Assert
-
// Should return null and clear corrupted storage
-
expect(result, isNull);
-
verify(mockStorage.read(key: storageKey)).called(1);
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
});
+
// Assert
+
// 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 {
// Arrange
-
when(mockStorage.read(key: storageKey))
-
.thenThrow(Exception('Storage error'));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.read(key: storageKey),
+
).thenThrow(Exception('Storage error'));
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
final result = await authService.restoreSession();
···
group('refreshToken()', () {
test('should throw StateError when no session exists', () async {
// Act & Assert
-
expect(
-
() => authService.refreshToken(),
-
throwsA(isA<StateError>()),
-
);
+
expect(() => authService.refreshToken(), throwsA(isA<StateError>()));
});
-
test('should successfully refresh token and return updated session',
-
() async {
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
handle: 'alice.bsky.social',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
+
test(
+
'should successfully refresh token and return updated session',
+
() async {
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
token: 'old-token',
+
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
+
// Mock successful refresh response
+
const newToken = 'new-refreshed-token';
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
));
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
-
// Act
-
final result = await authService.refreshToken();
+
// Act
+
final result = await authService.refreshToken();
-
// Assert
-
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
-
verify(mockStorage.write(
-
key: storageKey,
-
value: anyNamed('value'),
-
)).called(1);
-
});
+
// Assert
+
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>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
+
verify(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).called(1);
+
},
+
);
test('should throw "Session expired" on 401 response', () async {
// Arrange - First restore a session
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock 401 response
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenThrow(
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.badResponse,
···
expect(
() => authService.refreshToken(),
throwsA(
-
predicate((e) =>
-
e is Exception && e.toString().contains('Session expired')),
+
predicate(
+
(e) => e is Exception && e.toString().contains('Session expired'),
+
),
),
);
});
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock network error
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenThrow(
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
expect(
() => authService.refreshToken(),
throwsA(
-
predicate((e) =>
-
e is Exception &&
-
e.toString().contains('Token refresh failed')),
+
predicate(
+
(e) =>
+
e is Exception &&
+
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(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
+
test(
+
'should throw Exception when response is missing sealed_token',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'old-token',
+
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
+
// Mock response without sealed_token
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
statusCode: 200,
data: {'access_token': 'some-token'}, // No sealed_token field
-
));
+
),
+
);
-
// Act & Assert
-
expect(
-
() => authService.refreshToken(),
-
throwsA(
-
predicate((e) =>
-
e is Exception &&
-
e.toString().contains('Invalid refresh response')),
-
),
-
);
-
});
+
// Act & Assert
+
expect(
+
() => authService.refreshToken(),
+
throwsA(
+
predicate(
+
(e) =>
+
e is Exception &&
+
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(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
+
test(
+
'should throw Exception when response sealed_token is empty',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'old-token',
+
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
+
// Mock response with empty sealed_token
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
statusCode: 200,
-
data: {'sealed_token': '', 'access_token': 'some-token'}, // Empty sealed_token
-
));
+
data: {
+
'sealed_token': '',
+
'access_token': 'some-token',
+
}, // Empty sealed_token
+
),
+
);
-
// Act & Assert
-
expect(
-
() => authService.refreshToken(),
-
throwsA(
-
predicate((e) =>
-
e is Exception &&
-
e.toString().contains('Invalid refresh response')),
-
),
-
);
-
});
+
// Act & Assert
+
expect(
+
() => authService.refreshToken(),
+
throwsA(
+
predicate(
+
(e) =>
+
e is Exception &&
+
e.toString().contains('Invalid refresh response'),
+
),
+
),
+
);
+
},
+
);
});
group('signOut()', () {
-
test('should clear session and storage on successful server-side logout',
-
() async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
token: 'test-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
+
test(
+
'should clear session and storage on successful server-side logout',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'test-token',
+
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>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
+
// Mock successful logout
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/logout'),
statusCode: 200,
-
));
+
),
+
);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
-
// Act
-
await authService.signOut();
+
// Act
+
await authService.signOut();
-
// Assert
-
expect(authService.session, isNull);
-
expect(authService.isAuthenticated, isFalse);
-
verify(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).called(1);
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
});
+
// Assert
+
expect(authService.session, isNull);
+
expect(authService.isAuthenticated, isFalse);
+
verify(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).called(1);
+
verify(mockStorage.delete(key: storageKey)).called(1);
+
},
+
);
-
test('should clear local state even when server revocation fails',
-
() async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
token: 'test-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
+
test(
+
'should clear local state even when server revocation fails',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'test-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
-
// Mock server error
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenThrow(
-
DioException(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
type: DioExceptionType.connectionError,
-
message: 'Connection failed',
-
),
-
);
+
// Mock server error
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
type: DioExceptionType.connectionError,
+
message: 'Connection failed',
+
),
+
);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
-
// Act
-
await authService.signOut();
+
// Act
+
await authService.signOut();
-
// Assert
-
expect(authService.session, isNull);
-
expect(authService.isAuthenticated, isFalse);
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
});
+
// Assert
+
expect(authService.session, isNull);
+
expect(authService.isAuthenticated, isFalse);
+
verify(mockStorage.delete(key: storageKey)).called(1);
+
},
+
);
test('should work even when no session exists', () async {
// Arrange
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
await authService.signOut();
···
expect(authService.session, isNull);
expect(authService.isAuthenticated, isFalse);
verify(mockStorage.delete(key: storageKey)).called(1);
-
verifyNever(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
));
+
verifyNever(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
);
});
test('should clear local state even when storage delete fails', () async {
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
-
when(mockStorage.delete(key: storageKey))
-
.thenThrow(Exception('Storage error'));
+
when(
+
mockStorage.delete(key: storageKey),
+
).thenThrow(Exception('Storage error'));
// Act & Assert - Should not throw
expect(() => authService.signOut(), throwsA(isA<Exception>()));
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Act
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
await authService.signOut();
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Assert
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
await authService.signOut();
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
// Act
await authService.restoreSession();
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken = 'new-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
));
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act
await authService.refreshToken();
···
});
group('refreshToken() - Concurrency Protection', () {
-
test('should only make one API request for concurrent refresh calls',
-
() async {
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
handle: 'alice.bsky.social',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
+
test(
+
'should only make one API request for concurrent refresh calls',
+
() async {
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
token: 'old-token',
+
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';
+
const newToken = 'new-refreshed-token';
-
// Mock refresh response with a delay to simulate network latency
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
return Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
);
-
});
+
// Mock refresh response with a delay to simulate network latency
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
+
await Future.delayed(const Duration(milliseconds: 100));
+
return Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
);
+
});
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
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(),
-
]);
+
// 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);
+
// 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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
+
// Verify only one API call was made
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
-
// Verify only one storage write was made
-
verify(mockStorage.write(
-
key: storageKey,
-
value: anyNamed('value'),
-
)).called(1);
-
});
+
// Verify only one storage write was made
+
verify(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).called(1);
+
},
+
);
test('should propagate errors to all concurrent waiters', () async {
// Arrange - First restore a session
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock 401 response with delay
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
throw DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
···
expect(errorCount, 3);
// Verify only one API call was made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
});
test('should allow new refresh after previous one completes', () async {
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken1 = 'new-token-1';
const newToken2 = 'new-token-2';
// Mock first refresh
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken1, 'access_token': 'some-access-token'},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken1,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - First refresh
final result1 = await authService.refreshToken();
···
expect(result1.token, newToken1);
// Now update the mock for the second refresh
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken2, 'access_token': 'some-access-token'},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(2);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(2);
});
test('should allow new refresh after previous one fails', () async {
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
// Mock first refresh to fail
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenThrow(
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
DioException(
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
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>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(2);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(2);
});
test(
-
'should handle concurrent calls where one arrives after refresh completes',
-
() async {
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
+
'should handle concurrent calls where one arrives after refresh completes',
+
() async {
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
token: 'old-token',
+
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';
+
const newToken1 = 'new-token-1';
+
const newToken2 = 'new-token-2';
-
var callCount = 0;
+
var callCount = 0;
-
// Mock refresh with different responses
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
-
callCount++;
-
await Future.delayed(const Duration(milliseconds: 50));
-
return Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': callCount == 1 ? newToken1 : newToken2, 'access_token': 'some-access-token'},
-
);
-
});
+
// Mock refresh with different responses
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
+
callCount++;
+
await Future.delayed(const Duration(milliseconds: 50));
+
return Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': callCount == 1 ? newToken1 : newToken2,
+
'access_token': 'some-access-token',
+
},
+
);
+
});
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
-
// Act - Start first refresh
-
final future1 = authService.refreshToken();
+
// Act - Start first refresh
+
final future1 = authService.refreshToken();
-
// Wait for it to complete
-
final result1 = await future1;
+
// Wait for it to complete
+
final result1 = await future1;
-
// Start second refresh after first completes
-
final result2 = await authService.refreshToken();
+
// Start second refresh after first completes
+
final result2 = await authService.refreshToken();
-
// Assert
-
expect(result1.token, newToken1);
-
expect(result2.token, newToken2);
+
// Assert
+
expect(result1.token, newToken1);
+
expect(result2.token, newToken2);
-
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(2);
-
});
+
// Verify two separate API calls were made
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(2);
+
},
+
);
});
});
}
+178 -132
test/services/coves_auth_service_validation_test.dart
···
group('Handle Validation', () {
group('Valid inputs', () {
test('should accept standard handle format', () {
-
final result =
-
authService.validateAndNormalizeHandle('alice.bsky.social');
+
final result = authService.validateAndNormalizeHandle(
+
'alice.bsky.social',
+
);
expect(result, 'alice.bsky.social');
});
test('should accept handle with @ prefix and strip it', () {
-
final result =
-
authService.validateAndNormalizeHandle('@alice.bsky.social');
+
final result = authService.validateAndNormalizeHandle(
+
'@alice.bsky.social',
+
);
expect(result, 'alice.bsky.social');
});
-
test('should accept handle with leading/trailing whitespace and trim', () {
-
final result =
-
authService.validateAndNormalizeHandle(' alice.bsky.social ');
-
expect(result, 'alice.bsky.social');
-
});
+
test(
+
'should accept handle with leading/trailing whitespace and trim',
+
() {
+
final result = authService.validateAndNormalizeHandle(
+
' alice.bsky.social ',
+
);
+
expect(result, 'alice.bsky.social');
+
},
+
);
test('should accept handle with hyphen in segment', () {
-
final result =
-
authService.validateAndNormalizeHandle('alice-bob.bsky.social');
+
final result = authService.validateAndNormalizeHandle(
+
'alice-bob.bsky.social',
+
);
expect(result, 'alice-bob.bsky.social');
});
test('should accept handle with multiple hyphens', () {
-
final result = authService
-
.validateAndNormalizeHandle('alice-bob-charlie.bsky-app.social');
+
final result = authService.validateAndNormalizeHandle(
+
'alice-bob-charlie.bsky-app.social',
+
);
expect(result, 'alice-bob-charlie.bsky-app.social');
});
test('should accept handle with multiple subdomains', () {
-
final result = authService
-
.validateAndNormalizeHandle('alice.subdomain.example.com');
+
final result = authService.validateAndNormalizeHandle(
+
'alice.subdomain.example.com',
+
);
expect(result, 'alice.subdomain.example.com');
});
test('should accept handle with numbers', () {
-
final result =
-
authService.validateAndNormalizeHandle('user123.bsky.social');
+
final result = authService.validateAndNormalizeHandle(
+
'user123.bsky.social',
+
);
expect(result, 'user123.bsky.social');
});
test('should convert handle to lowercase', () {
-
final result =
-
authService.validateAndNormalizeHandle('Alice.Bsky.Social');
+
final result = authService.validateAndNormalizeHandle(
+
'Alice.Bsky.Social',
+
);
expect(result, 'alice.bsky.social');
});
-
test('should extract and validate handle from Bluesky profile URL (HTTP)', () {
-
final result = authService.validateAndNormalizeHandle(
-
'http://bsky.app/profile/alice.bsky.social');
-
expect(result, 'alice.bsky.social');
-
});
+
test(
+
'should extract and validate handle from Bluesky profile URL (HTTP)',
+
() {
+
final result = authService.validateAndNormalizeHandle(
+
'http://bsky.app/profile/alice.bsky.social',
+
);
+
expect(result, 'alice.bsky.social');
+
},
+
);
-
test('should extract and validate handle from Bluesky profile URL (HTTPS)', () {
-
final result = authService.validateAndNormalizeHandle(
-
'https://bsky.app/profile/alice.bsky.social');
-
expect(result, 'alice.bsky.social');
-
});
+
test(
+
'should extract and validate handle from Bluesky profile URL (HTTPS)',
+
() {
+
final result = authService.validateAndNormalizeHandle(
+
'https://bsky.app/profile/alice.bsky.social',
+
);
+
expect(result, 'alice.bsky.social');
+
},
+
);
-
test('should extract and validate handle from Bluesky profile URL with www', () {
-
final result = authService.validateAndNormalizeHandle(
-
'https://www.bsky.app/profile/alice.bsky.social');
-
expect(result, 'alice.bsky.social');
-
});
+
test(
+
'should extract and validate handle from Bluesky profile URL with www',
+
() {
+
final result = authService.validateAndNormalizeHandle(
+
'https://www.bsky.app/profile/alice.bsky.social',
+
);
+
expect(result, 'alice.bsky.social');
+
},
+
);
test('should accept DID with plc method', () {
-
final result =
-
authService.validateAndNormalizeHandle('did:plc:abc123def456');
+
final result = authService.validateAndNormalizeHandle(
+
'did:plc:abc123def456',
+
);
expect(result, 'did:plc:abc123def456');
});
test('should accept DID with web method', () {
-
final result =
-
authService.validateAndNormalizeHandle('did:web:example.com');
+
final result = authService.validateAndNormalizeHandle(
+
'did:web:example.com',
+
);
expect(result, 'did:web:example.com');
});
test('should accept DID with complex identifier', () {
-
final result = authService
-
.validateAndNormalizeHandle('did:plc:z72i7hdynmk6r22z27h6tvur');
+
final result = authService.validateAndNormalizeHandle(
+
'did:plc:z72i7hdynmk6r22z27h6tvur',
+
);
expect(result, 'did:plc:z72i7hdynmk6r22z27h6tvur');
});
test('should accept DID with periods and colons in identifier', () {
-
final result = authService
-
.validateAndNormalizeHandle('did:web:example.com:user:alice');
+
final result = authService.validateAndNormalizeHandle(
+
'did:web:example.com:user:alice',
+
);
expect(result, 'did:web:example.com:user:alice');
});
···
});
test('should normalize handle with @ prefix and whitespace', () {
-
final result =
-
authService.validateAndNormalizeHandle(' @Alice.Bsky.Social ');
+
final result = authService.validateAndNormalizeHandle(
+
' @Alice.Bsky.Social ',
+
);
expect(result, 'alice.bsky.social');
});
test('should accept handle with numeric first segment', () {
-
final result =
-
authService.validateAndNormalizeHandle('123.bsky.social');
+
final result = authService.validateAndNormalizeHandle(
+
'123.bsky.social',
+
);
expect(result, '123.bsky.social');
});
test('should accept handle with numeric middle segment', () {
-
final result =
-
authService.validateAndNormalizeHandle('alice.456.social');
+
final result = authService.validateAndNormalizeHandle(
+
'alice.456.social',
+
);
expect(result, 'alice.456.social');
});
test('should accept handle with multiple numeric segments', () {
-
final result =
-
authService.validateAndNormalizeHandle('42.example.com');
+
final result = authService.validateAndNormalizeHandle('42.example.com');
expect(result, '42.example.com');
});
···
});
test('should accept handle with numeric and alpha mixed', () {
-
final result =
-
authService.validateAndNormalizeHandle('8.cn');
+
final result = authService.validateAndNormalizeHandle('8.cn');
expect(result, '8.cn');
});
test('should accept handle like IP but with valid TLD', () {
-
final result =
-
authService.validateAndNormalizeHandle('120.0.0.1.com');
+
final result = authService.validateAndNormalizeHandle('120.0.0.1.com');
expect(result, '120.0.0.1.com');
});
});
···
);
});
-
test('should throw ArgumentError for handle with consecutive periods', () {
-
expect(
-
() => authService.validateAndNormalizeHandle('alice..bsky.social'),
-
throwsA(
-
predicate(
-
(e) =>
-
e is ArgumentError &&
-
(e.message.toString().contains('empty segments') ||
-
e.message.toString().contains('Invalid handle format')),
+
test(
+
'should throw ArgumentError for handle with consecutive periods',
+
() {
+
expect(
+
() => authService.validateAndNormalizeHandle('alice..bsky.social'),
+
throwsA(
+
predicate(
+
(e) =>
+
e is ArgumentError &&
+
(e.message.toString().contains('empty segments') ||
+
e.message.toString().contains('Invalid handle format')),
+
),
),
-
),
-
);
-
});
+
);
+
},
+
);
test('should throw ArgumentError for handle with spaces', () {
expect(
···
);
});
-
test('should throw ArgumentError for handle exceeding 253 characters', () {
-
// Create a handle that's 254 characters long
-
final longHandle = '${'a' * 240}.bsky.social';
-
expect(
-
() => authService.validateAndNormalizeHandle(longHandle),
-
throwsA(
-
predicate(
-
(e) =>
-
e is ArgumentError &&
-
e.message.toString().contains('too long'),
+
test(
+
'should throw ArgumentError for handle exceeding 253 characters',
+
() {
+
// Create a handle that's 254 characters long
+
final longHandle = '${'a' * 240}.bsky.social';
+
expect(
+
() => authService.validateAndNormalizeHandle(longHandle),
+
throwsA(
+
predicate(
+
(e) =>
+
e is ArgumentError &&
+
e.message.toString().contains('too long'),
+
),
),
-
),
-
);
-
});
+
);
+
},
+
);
-
test('should throw ArgumentError for segment exceeding 63 characters', () {
-
// DNS label limit is 63 characters per segment
-
final longSegment = '${'a' * 64}.bsky.social';
-
expect(
-
() => authService.validateAndNormalizeHandle(longSegment),
-
throwsA(
-
predicate(
-
(e) =>
-
e is ArgumentError &&
-
e.message.toString().contains('too long'),
+
test(
+
'should throw ArgumentError for segment exceeding 63 characters',
+
() {
+
// DNS label limit is 63 characters per segment
+
final longSegment = '${'a' * 64}.bsky.social';
+
expect(
+
() => authService.validateAndNormalizeHandle(longSegment),
+
throwsA(
+
predicate(
+
(e) =>
+
e is ArgumentError &&
+
e.message.toString().contains('too long'),
+
),
),
-
),
-
);
-
});
+
);
+
},
+
);
test('should throw ArgumentError for TLD starting with digit', () {
expect(
···
);
});
-
test('should throw ArgumentError for IPv4 address (TLD starts with digit)', () {
-
expect(
-
() => authService.validateAndNormalizeHandle('127.0.0.1'),
-
throwsA(
-
predicate(
-
(e) =>
-
e is ArgumentError &&
-
e.message.toString().contains('TLD') &&
-
e.message.toString().contains('cannot start with a digit'),
+
test(
+
'should throw ArgumentError for IPv4 address (TLD starts with digit)',
+
() {
+
expect(
+
() => authService.validateAndNormalizeHandle('127.0.0.1'),
+
throwsA(
+
predicate(
+
(e) =>
+
e is ArgumentError &&
+
e.message.toString().contains('TLD') &&
+
e.message.toString().contains('cannot start with a digit'),
+
),
),
-
),
-
);
-
});
+
);
+
},
+
);
test('should throw ArgumentError for IPv4 address variant', () {
expect(
···
group('DID Validation', () {
test('should accept valid plc DID', () {
-
final result =
-
authService.validateAndNormalizeHandle('did:plc:abc123');
+
final result = authService.validateAndNormalizeHandle('did:plc:abc123');
expect(result, 'did:plc:abc123');
});
test('should accept valid web DID', () {
-
final result =
-
authService.validateAndNormalizeHandle('did:web:example.com');
+
final result = authService.validateAndNormalizeHandle(
+
'did:web:example.com',
+
);
expect(result, 'did:web:example.com');
});
test('should accept DID with underscores in identifier', () {
// Underscores are allowed in the DID pattern (part of [a-zA-Z0-9._:%-]+)
-
final result =
-
authService.validateAndNormalizeHandle('did:plc:abc_123');
+
final result = authService.validateAndNormalizeHandle(
+
'did:plc:abc_123',
+
);
expect(result, 'did:plc:abc_123');
});
-
test('should throw ArgumentError for invalid DID with @ special chars', () {
-
expect(
-
() => authService.validateAndNormalizeHandle('did:plc:abc@123'),
-
throwsA(
-
predicate(
-
(e) =>
-
e is ArgumentError &&
-
e.message.toString().contains('Invalid DID format'),
+
test(
+
'should throw ArgumentError for invalid DID with @ special chars',
+
() {
+
expect(
+
() => authService.validateAndNormalizeHandle('did:plc:abc@123'),
+
throwsA(
+
predicate(
+
(e) =>
+
e is ArgumentError &&
+
e.message.toString().contains('Invalid DID format'),
+
),
),
-
),
-
);
-
});
+
);
+
},
+
);
test('should throw ArgumentError for DID with uppercase method', () {
expect(
···
);
});
-
test('should throw ArgumentError for malformed DID (missing identifier)', () {
-
expect(
-
() => authService.validateAndNormalizeHandle('did:plc'),
-
throwsA(
-
predicate(
-
(e) =>
-
e is ArgumentError &&
-
e.message.toString().contains('Invalid DID format'),
+
test(
+
'should throw ArgumentError for malformed DID (missing identifier)',
+
() {
+
expect(
+
() => authService.validateAndNormalizeHandle('did:plc'),
+
throwsA(
+
predicate(
+
(e) =>
+
e is ArgumentError &&
+
e.message.toString().contains('Invalid DID format'),
+
),
),
-
),
-
);
-
});
+
);
+
},
+
);
test('should throw ArgumentError for malformed DID (missing method)', () {
expect(
+52 -67
test/services/vote_service_token_refresh_test.dart
···
'message': 'Token expired',
}),
data: {
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
-
},
+
'subject': {'uri': postUri, 'cid': postCid},
'direction': 'up',
},
);
···
'message': 'Token expired',
}),
data: {
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
-
},
+
'subject': {'uri': postUri, 'cid': postCid},
'direction': 'up',
},
);
···
expect(signOutCallCount, 1);
});
-
test('should handle 401 gracefully when no refresher is provided',
-
() async {
-
// Create a NEW dio instance to avoid sharing interceptors
-
final dioNoRefresh = Dio(BaseOptions(baseUrl: 'https://api.test.coves.social'));
-
final dioAdapterNoRefresh = DioAdapter(dio: dioNoRefresh);
+
test(
+
'should handle 401 gracefully when no refresher is provided',
+
() async {
+
// Create a NEW dio instance to avoid sharing interceptors
+
final dioNoRefresh = Dio(
+
BaseOptions(baseUrl: 'https://api.test.coves.social'),
+
);
+
final dioAdapterNoRefresh = DioAdapter(dio: dioNoRefresh);
-
// Create vote service without refresh capability
-
final voteServiceNoRefresh = VoteService(
-
dio: dioNoRefresh,
-
sessionGetter: mockSessionGetter,
-
didGetter: mockDidGetter,
-
// No tokenRefresher provided
-
// No signOutHandler provided
-
);
+
// Create vote service without refresh capability
+
final voteServiceNoRefresh = VoteService(
+
dio: dioNoRefresh,
+
sessionGetter: mockSessionGetter,
+
didGetter: mockDidGetter,
+
// No tokenRefresher provided
+
// No signOutHandler provided
+
);
-
const postUri = 'at://did:plc:test/social.coves.post.record/123';
-
const postCid = 'bafy123';
+
const postUri = 'at://did:plc:test/social.coves.post.record/123';
+
const postCid = 'bafy123';
-
// Request returns 401
-
dioAdapterNoRefresh.onPost(
-
'/xrpc/social.coves.feed.vote.create',
-
(server) => server.reply(401, {
-
'error': 'Unauthorized',
-
'message': 'Token expired',
-
}),
-
data: {
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
+
// Request returns 401
+
dioAdapterNoRefresh.onPost(
+
'/xrpc/social.coves.feed.vote.create',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Token expired',
+
}),
+
data: {
+
'subject': {'uri': postUri, 'cid': postCid},
+
'direction': 'up',
},
-
'direction': 'up',
-
},
-
);
+
);
-
// Make the request and expect it to fail
-
expect(
-
() => voteServiceNoRefresh.createVote(
-
postUri: postUri,
-
postCid: postCid,
-
direction: 'up',
-
),
-
throwsA(isA<Exception>()),
-
);
+
// Make the request and expect it to fail
+
expect(
+
() => voteServiceNoRefresh.createVote(
+
postUri: postUri,
+
postCid: postCid,
+
direction: 'up',
+
),
+
throwsA(isA<Exception>()),
+
);
-
// Wait for async operations
-
await Future.delayed(const Duration(milliseconds: 100));
+
// Wait for async operations
+
await Future.delayed(const Duration(milliseconds: 100));
-
// Verify refresh was NOT called (no refresher provided)
-
expect(tokenRefreshCallCount, 0);
+
// Verify refresh was NOT called (no refresher provided)
+
expect(tokenRefreshCallCount, 0);
-
// Verify sign-out was NOT called (no handler provided)
-
expect(signOutCallCount, 0);
-
});
+
// Verify sign-out was NOT called (no handler provided)
+
expect(signOutCallCount, 0);
+
},
+
);
test('should handle non-401 errors normally without refresh', () async {
const postUri = 'at://did:plc:test/social.coves.post.record/123';
···
'message': 'Database connection failed',
}),
data: {
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
-
},
+
'subject': {'uri': postUri, 'cid': postCid},
'direction': 'up',
},
);
···
'error': 'Unauthorized',
'message': 'Token expired',
}),
-
data: {
-
'rkey': rkey,
-
},
+
data: {'rkey': rkey},
);
// Create vote with existing vote (will trigger delete)
···
'cid': 'bafy456',
}),
data: {
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
-
},
+
'subject': {'uri': postUri, 'cid': postCid},
'direction': 'up',
},
);
···
dioAdapter.onPost(
'/xrpc/social.coves.feed.vote.delete',
(server) => server.reply(200, {}),
-
data: {
-
'rkey': 'xyz',
-
},
+
data: {'rkey': 'xyz'},
);
// Make second request (delete vote)