diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index b23c873..b9393f0 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -26,9 +26,9 @@ class CovesApiService { Future Function()? tokenRefresher, Future Function()? signOutHandler, Dio? dio, - }) : _tokenGetter = tokenGetter, - _tokenRefresher = tokenRefresher, - _signOutHandler = signOutHandler { + }) : _tokenGetter = tokenGetter, + _tokenRefresher = tokenRefresher, + _signOutHandler = signOutHandler { _dio = dio ?? Dio( @@ -78,8 +78,9 @@ class CovesApiService { } // 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( @@ -160,7 +161,8 @@ class CovesApiService { } // 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; @@ -352,10 +354,7 @@ class CovesApiService { 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); } } diff --git a/lib/services/coves_auth_service.dart b/lib/services/coves_auth_service.dart index d13b5f2..87da0b0 100644 --- a/lib/services/coves_auth_service.dart +++ b/lib/services/coves_auth_service.dart @@ -26,24 +26,20 @@ import '../models/coves_session.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; @@ -341,9 +337,7 @@ class CovesAuthService { await _dio.post( '/oauth/logout', options: Options( - headers: { - 'Authorization': 'Bearer ${_session!.token}', - }, + headers: {'Authorization': 'Bearer ${_session!.token}'}, ), ); @@ -520,10 +514,7 @@ class CovesAuthService { /// Save session to secure storage Future _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 diff --git a/lib/services/vote_service.dart b/lib/services/vote_service.dart index 520da8f..df0eab8 100644 --- a/lib/services/vote_service.dart +++ b/lib/services/vote_service.dart @@ -35,7 +35,8 @@ class VoteService { _didGetter = didGetter, _tokenRefresher = tokenRefresher, _signOutHandler = signOutHandler { - _dio = dio ?? + _dio = + dio ?? Dio( BaseOptions( baseUrl: EnvironmentConfig.current.apiUrl, @@ -70,7 +71,9 @@ class VoteService { // 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) @@ -94,7 +97,9 @@ class VoteService { if (refreshSucceeded) { if (kDebugMode) { - debugPrint('✅ VoteService: Token refresh successful, retrying request'); + debugPrint( + '✅ VoteService: Token refresh successful, retrying request', + ); } // Get the new session @@ -128,7 +133,9 @@ class VoteService { // 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(); @@ -139,7 +146,8 @@ class VoteService { } // 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; @@ -258,10 +266,7 @@ class VoteService { if (isToggleOff) { // Delete existing vote - return _deleteVote( - session: session, - rkey: existingVoteRkey, - ); + return _deleteVote(session: session, rkey: existingVoteRkey); } // If switching direction, delete old vote first @@ -277,10 +282,7 @@ class VoteService { final response = await _dio.post>( '/xrpc/social.coves.feed.vote.create', data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': direction, }, ); @@ -338,9 +340,7 @@ class VoteService { // Note: Authorization header is added by the interceptor await _dio.post( '/xrpc/social.coves.feed.vote.delete', - data: { - 'rkey': rkey, - }, + data: {'rkey': rkey}, ); if (kDebugMode) { diff --git a/lib/widgets/comment_card.dart b/lib/widgets/comment_card.dart index d20a655..6c314dd 100644 --- a/lib/widgets/comment_card.dart +++ b/lib/widgets/comment_card.dart @@ -276,7 +276,6 @@ class CommentCard extends StatelessWidget { /// Custom painter for drawing comment depth indicator lines class _CommentDepthPainter extends CustomPainter { - _CommentDepthPainter({required this.depth}); final int depth; diff --git a/lib/widgets/icons/bluesky_icons.dart b/lib/widgets/icons/bluesky_icons.dart index e5e1be6..cc8a67a 100644 --- a/lib/widgets/icons/bluesky_icons.dart +++ b/lib/widgets/icons/bluesky_icons.dart @@ -4,7 +4,6 @@ import 'package:flutter_svg/flutter_svg.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, diff --git a/lib/widgets/post_action_bar.dart b/lib/widgets/post_action_bar.dart index 2f1e6b8..3ea3308 100644 --- a/lib/widgets/post_action_bar.dart +++ b/lib/widgets/post_action_bar.dart @@ -49,9 +49,7 @@ class PostActionBar extends StatelessWidget { 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( diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index 9adc07a..274546d 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -161,21 +161,21 @@ class PostCard extends StatelessWidget { ), ) 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) ...[ @@ -590,10 +590,7 @@ class _EmbedCardState extends State<_EmbedCard> { // 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 diff --git a/lib/widgets/post_card_actions.dart b/lib/widgets/post_card_actions.dart index 322c35f..0e8d2ee 100644 --- a/lib/widgets/post_card_actions.dart +++ b/lib/widgets/post_card_actions.dart @@ -120,19 +120,17 @@ class PostCardActions extends StatelessWidget { 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, ), ), diff --git a/test/models/coves_session_test.dart b/test/models/coves_session_test.dart index 0badb4f..8b4c470 100644 --- a/test/models/coves_session_test.dart +++ b/test/models/coves_session_test.dart @@ -233,10 +233,7 @@ void main() { 'session_id': 'sess456', }; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when did has wrong type', () { @@ -246,10 +243,7 @@ void main() { 'session_id': 'sess456', }; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when session_id has wrong type', () { @@ -259,46 +253,25 @@ void main() { 'session_id': 123, // Should be String }; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); 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()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); 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()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); 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()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should handle extra fields in JSON', () { @@ -793,7 +766,10 @@ void main() { final stringRep = session.toString(); expect(stringRep, isNot(contains('Bearer'))); - expect(stringRep, isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))); + expect( + stringRep, + isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')), + ); }); }); } diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 5f43ce1..5b80fc2 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -152,8 +152,9 @@ void main() { 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(); diff --git a/test/providers/comments_provider_test.dart b/test/providers/comments_provider_test.dart index 2270b6c..2444b97 100644 --- a/test/providers/comments_provider_test.dart +++ b/test/providers/comments_provider_test.dart @@ -89,10 +89,7 @@ void main() { }); test('should handle empty comments response', () async { - final mockResponse = CommentsResponse( - post: {}, - comments: [], - ); + final mockResponse = CommentsResponse(post: {}, comments: []); when( mockApiService.getComments( @@ -211,9 +208,7 @@ void main() { ), ).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'); @@ -425,10 +420,7 @@ void main() { 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( @@ -494,10 +486,7 @@ void main() { 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( diff --git a/test/services/coves_api_service_token_refresh_test.dart b/test/services/coves_api_service_token_refresh_test.dart index 34b3baa..a2652f8 100644 --- a/test/services/coves_api_service_token_refresh_test.dart +++ b/test/services/coves_api_service_token_refresh_test.dart @@ -60,8 +60,7 @@ void main() { 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). @@ -138,92 +137,100 @@ void main() { 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. - - // Set refresh to fail (simulates refresh endpoint returning 401) - shouldRefreshSucceed = false; - - 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, - }, - ); - - // Make the request and expect it to fail - expect( - () => apiService.getComments(postUri: postUri), - throwsA(isA()), - ); - - // Wait for async operations to complete - await Future.delayed(const Duration(milliseconds: 100)); - - // 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 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'; - - // 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()), - ); - - // Verify refresh was NOT called (no refresher provided) - expect(tokenRefreshCallCount, 0); - - // Verify sign-out was NOT called (no handler provided) - expect(signOutCallCount, 0); - - apiServiceNoRefresh.dispose(); - }); + 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; + + 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, + }, + ); + + // Make the request and expect it to fail + expect( + () => apiService.getComments(postUri: postUri), + throwsA(isA()), + ); + + // Wait for async operations to complete + await Future.delayed(const Duration(milliseconds: 100)); + + // 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 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'; + + // 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()), + ); + + // Verify refresh was NOT called (no refresher provided) + expect(tokenRefreshCallCount, 0); + + // Verify sign-out was NOT called (no handler provided) + expect(signOutCallCount, 0); + + apiServiceNoRefresh.dispose(); + }, + ); // Skipped: http_mock_adapter cannot handle stateful request/response cycles diff --git a/test/services/coves_auth_service_environment_test.dart b/test/services/coves_auth_service_environment_test.dart index 3f64d9f..af6caa4 100644 --- a/test/services/coves_auth_service_environment_test.dart +++ b/test/services/coves_auth_service_environment_test.dart @@ -70,8 +70,9 @@ void main() { ); // 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(); @@ -100,36 +101,42 @@ void main() { 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>( - '/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>( + '/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 - 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 { @@ -144,18 +151,20 @@ void main() { 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( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/logout'), - statusCode: 200, - )); + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + ), + ); when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); diff --git a/test/services/coves_auth_service_redaction_test.dart b/test/services/coves_auth_service_redaction_test.dart index 7bbb928..553bb4a 100644 --- a/test/services/coves_auth_service_redaction_test.dart +++ b/test/services/coves_auth_service_redaction_test.dart @@ -52,21 +52,23 @@ void main() { ); }); - 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]', - ); - - 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 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]', + ); + + 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 = @@ -77,18 +79,25 @@ void main() { (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', () { @@ -111,26 +120,34 @@ void main() { (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]+)'), diff --git a/test/services/coves_auth_service_singleton_test.dart b/test/services/coves_auth_service_singleton_test.dart index 11956c8..a443fd4 100644 --- a/test/services/coves_auth_service_singleton_test.dart +++ b/test/services/coves_auth_service_singleton_test.dart @@ -34,12 +34,21 @@ void main() { 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 { @@ -47,15 +56,17 @@ void main() { 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(); @@ -77,24 +88,28 @@ void main() { 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async { + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 100)); return Response( requestOptions: RequestOptions(path: '/oauth/refresh'), @@ -103,8 +118,9 @@ void main() { ); }); - 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(); @@ -121,10 +137,12 @@ void main() { expect(results[1].token, newToken); // Verify only one API call was made (mutex protected) - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); }); test('resetInstance() should clear the singleton', () { @@ -148,7 +166,10 @@ void main() { 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(); @@ -159,45 +180,59 @@ void main() { ); // 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); - - const sessionJson = '{' - '"token": "test-token", ' - '"did": "did:plc:test123", ' - '"session_id": "session-123"' - '}'; - - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => sessionJson); - - // Act - Simulate different parts of the app requesting the service - await authProvider.restoreSession(); - - 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'); + test( + 'should avoid state loss when service is requested from multiple entry points', + () async { + // Arrange + final authProvider = CovesAuthService( + dio: mockDio, + storage: mockStorage, + ); - // Storage should only be read once - verify(mockStorage.read(key: storageKey)).called(1); - }); + const sessionJson = + '{' + '"token": "test-token", ' + '"did": "did:plc:test123", ' + '"session_id": "session-123"' + '}'; + + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => sessionJson); + + // Act - Simulate different parts of the app requesting the service + await authProvider.restoreSession(); + + 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'); + + // Storage should only be read once + verify(mockStorage.read(key: storageKey)).called(1); + }, + ); }); } diff --git a/test/services/coves_auth_service_test.dart b/test/services/coves_auth_service_test.dart index dd1bd0b..f6bb8d6 100644 --- a/test/services/coves_auth_service_test.dart +++ b/test/services/coves_auth_service_test.dart @@ -35,22 +35,20 @@ void main() { group('CovesAuthService', () { group('signIn()', () { test('should throw ArgumentError when handle is empty', () async { - expect( - () => authService.signIn(''), - throwsA(isA()), - ); + expect(() => authService.signIn(''), throwsA(isA())); }); - test('should throw ArgumentError when handle is whitespace-only', - () async { - expect( - () => authService.signIn(' '), - throwsA(isA()), - ); - }); + test( + 'should throw ArgumentError when handle is whitespace-only', + () async { + expect( + () => authService.signIn(' '), + throwsA(isA()), + ); + }, + ); - 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 @@ -60,12 +58,14 @@ void main() { // 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 @@ -86,8 +86,9 @@ void main() { ); 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(); @@ -103,8 +104,7 @@ void main() { 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(); @@ -117,10 +117,10 @@ void main() { 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(); @@ -131,31 +131,34 @@ void main() { 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 => {}); - - // 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); - }); + 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(); + + // 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(); @@ -169,56 +172,65 @@ void main() { group('refreshToken()', () { test('should throw StateError when no session exists', () async { // Act & Assert - expect( - () => authService.refreshToken(), - throwsA(isA()), - ); + expect(() => authService.refreshToken(), throwsA(isA())); }); - 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( + 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>( + '/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 - final result = await authService.refreshToken(); + data: { + 'sealed_token': newToken, + 'access_token': 'some-access-token', + }, + ), + ); - // 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); - verify(mockStorage.write( - key: storageKey, - value: anyNamed('value'), - )).called(1); - }); + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); + + // 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>( + '/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 @@ -227,15 +239,18 @@ void main() { 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenThrow( + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenThrow( DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), type: DioExceptionType.badResponse, @@ -250,8 +265,9 @@ void main() { expect( () => authService.refreshToken(), throwsA( - predicate((e) => - e is Exception && e.toString().contains('Session expired')), + predicate( + (e) => e is Exception && e.toString().contains('Session expired'), + ), ), ); }); @@ -263,15 +279,18 @@ void main() { 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenThrow( + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenThrow( DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), type: DioExceptionType.connectionError, @@ -283,156 +302,183 @@ void main() { 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(); - - // Mock response without sealed_token - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( + 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>( + '/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')), - ), - ); - }); + ), + ); - 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(); + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate( + (e) => + e is Exception && + e.toString().contains('Invalid refresh response'), + ), + ), + ); + }, + ); - // Mock response with empty sealed_token - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => 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(); + + // Mock response with empty sealed_token + when( + mockDio.post>( + '/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(); - - // Mock successful logout - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( + 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('/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( - '/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('/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(); - - // Mock server error - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenThrow( - DioException( - requestOptions: RequestOptions(path: '/oauth/logout'), - type: DioExceptionType.connectionError, - message: 'Connection failed', - ), - ); + 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('/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(); @@ -441,10 +487,9 @@ void main() { expect(authService.session, isNull); expect(authService.isAuthenticated, isFalse); verify(mockStorage.delete(key: storageKey)).called(1); - verifyNever(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )); + verifyNever( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ); }); test('should clear local state even when storage delete fails', () async { @@ -454,20 +499,23 @@ void main() { 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( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/logout'), - statusCode: 200, - )); + when( + mockDio.post('/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())); @@ -485,8 +533,9 @@ void main() { 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 @@ -511,19 +560,20 @@ void main() { 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( - '/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('/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(); @@ -547,8 +597,9 @@ void main() { 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 @@ -562,19 +613,20 @@ void main() { 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( - '/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('/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(); @@ -592,8 +644,9 @@ void main() { 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(); @@ -611,21 +664,30 @@ void main() { 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>( - '/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>( + '/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(); @@ -637,62 +699,72 @@ void main() { }); 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(); - - const newToken = 'new-refreshed-token'; - - // Mock refresh response with a delay to simulate network latency - when(mockDio.post>( - '/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'}, + 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.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); + 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>( + '/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 => {}); + + // 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>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); - // Verify only one API call was made - verify(mockDio.post>( - '/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 @@ -701,15 +773,18 @@ void main() { 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async { + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 100)); throw DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), @@ -744,10 +819,12 @@ void main() { expect(errorCount, 3); // Verify only one API call was made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); }); test('should allow new refresh after previous one completes', () async { @@ -757,25 +834,34 @@ void main() { 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>( - '/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>( + '/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(); @@ -784,14 +870,21 @@ void main() { expect(result1.token, newToken1); // Now update the mock for the second refresh - when(mockDio.post>( - '/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>( + '/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(); @@ -800,10 +893,12 @@ void main() { expect(result2.token, newToken2); // Verify two separate API calls were made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(2); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(2); }); test('should allow new refresh after previous one fails', () async { @@ -813,15 +908,18 @@ void main() { 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>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenThrow( + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenThrow( DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), type: DioExceptionType.connectionError, @@ -845,17 +943,25 @@ void main() { // Now mock a successful second refresh const newToken = 'new-token-after-retry'; - when(mockDio.post>( - '/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>( + '/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(); @@ -864,66 +970,78 @@ void main() { expect(result.token, newToken); // Verify two separate API calls were made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(2); + verify( + mockDio.post>( + '/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(); - - const newToken1 = 'new-token-1'; - const newToken2 = 'new-token-2'; - - var callCount = 0; - - // Mock refresh with different responses - when(mockDio.post>( - '/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'}, + '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.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); - - // Act - Start first refresh - final future1 = authService.refreshToken(); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); - // Wait for it to complete - final result1 = await future1; + const newToken1 = 'new-token-1'; + const newToken2 = 'new-token-2'; - // Start second refresh after first completes - final result2 = await authService.refreshToken(); + var callCount = 0; - // Assert - expect(result1.token, newToken1); - expect(result2.token, newToken2); - - // Verify two separate API calls were made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(2); - }); + // Mock refresh with different responses + when( + mockDio.post>( + '/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 => {}); + + // 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(); + + // Assert + expect(result1.token, newToken1); + expect(result2.token, newToken2); + + // Verify two separate API calls were made + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(2); + }, + ); }); }); } diff --git a/test/services/coves_auth_service_validation_test.dart b/test/services/coves_auth_service_validation_test.dart index f019f31..d8004ad 100644 --- a/test/services/coves_auth_service_validation_test.dart +++ b/test/services/coves_auth_service_validation_test.dart @@ -30,92 +30,119 @@ void main() { 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'); - 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'); + '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 (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 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'); }); @@ -125,26 +152,28 @@ void main() { }); 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'); }); @@ -154,14 +183,12 @@ void main() { }); 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'); }); }); @@ -259,19 +286,22 @@ void main() { ); }); - 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( @@ -338,35 +368,41 @@ void main() { ); }); - 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( @@ -396,19 +432,22 @@ void main() { ); }); - 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( @@ -427,36 +466,40 @@ void main() { 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( @@ -484,18 +527,21 @@ void main() { ); }); - 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( diff --git a/test/services/vote_service_token_refresh_test.dart b/test/services/vote_service_token_refresh_test.dart index 172073a..dfac38f 100644 --- a/test/services/vote_service_token_refresh_test.dart +++ b/test/services/vote_service_token_refresh_test.dart @@ -90,10 +90,7 @@ void main() { 'message': 'Token expired', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -136,10 +133,7 @@ void main() { 'message': 'Token expired', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -164,59 +158,60 @@ void main() { 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); - - // 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'; + 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 + ); - // 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, + 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}, + 'direction': 'up', }, - 'direction': 'up', - }, - ); + ); - // Make the request and expect it to fail - expect( - () => voteServiceNoRefresh.createVote( - postUri: postUri, - postCid: postCid, - direction: 'up', - ), - throwsA(isA()), - ); + // Make the request and expect it to fail + expect( + () => voteServiceNoRefresh.createVote( + postUri: postUri, + postCid: postCid, + direction: 'up', + ), + throwsA(isA()), + ); - // 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'; @@ -230,10 +225,7 @@ void main() { 'message': 'Database connection failed', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -268,9 +260,7 @@ void main() { 'error': 'Unauthorized', 'message': 'Token expired', }), - data: { - 'rkey': rkey, - }, + data: {'rkey': rkey}, ); // Create vote with existing vote (will trigger delete) @@ -338,10 +328,7 @@ void main() { 'cid': 'bafy456', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -364,9 +351,7 @@ void main() { dioAdapter.onPost( '/xrpc/social.coves.feed.vote.delete', (server) => server.reply(200, {}), - data: { - 'rkey': 'xyz', - }, + data: {'rkey': 'xyz'}, ); // Make second request (delete vote)