diff --git a/.gitignore b/.gitignore index af88cda2..e1d08f94 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ packages/**/pubspec.lock !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 + +/packages/*/coverage \ No newline at end of file diff --git a/packages/gotrue/lib/src/broadcast_stub.dart b/packages/gotrue/lib/src/broadcast_stub.dart index 95ee5d0e..9bdaed18 100644 --- a/packages/gotrue/lib/src/broadcast_stub.dart +++ b/packages/gotrue/lib/src/broadcast_stub.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'package:gotrue/src/types/types.dart'; /// Stub implementation of [BroadcastChannel] for platforms that don't support it. diff --git a/packages/gotrue/test/mocks/otp_mock_client.dart b/packages/gotrue/test/mocks/otp_mock_client.dart new file mode 100644 index 00000000..306a3dd9 --- /dev/null +++ b/packages/gotrue/test/mocks/otp_mock_client.dart @@ -0,0 +1,472 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +/// A mock HTTP client that simulates OTP-related API responses. +class OtpMockClient extends BaseClient { + final String phoneNumber; + final String email; + final String userId; + final String accessToken; + final String refreshToken; + + OtpMockClient({ + this.phoneNumber = '+11234567890', + this.email = 'test@example.com', + this.userId = 'mock-user-id-123', + this.accessToken = 'mock-access-token', + this.refreshToken = 'mock-refresh-token', + }); + + @override + Future send(BaseRequest request) async { + final url = request.url.toString(); + final method = request.method; + + // Extract request body if it's a POST request + Map? requestBody; + if (request is Request) { + try { + requestBody = json.decode(request.body) as Map; + } catch (_) { + // If body is not valid JSON, ignore the error + } + } + + // Simulate OTP request (send OTP) + if (url.contains('/otp') && method == 'POST') { + return _handleOtpRequest(requestBody); + } + + // Simulate OTP verification + if (url.contains('/verify') && method == 'POST') { + return _handleVerifyRequest(requestBody); + } + + // Simulate phone number signup + if (url.contains('/signup') && + method == 'POST' && + requestBody?['phone'] != null) { + return _handlePhoneSignup(requestBody); + } + + // Simulate token endpoint for password auth with phone + if (url.contains('/token') && + method == 'POST' && + requestBody?['phone'] != null) { + return _handlePhoneSignInWithPassword(requestBody); + } + + // Simulate reauthentication + if (url.contains('/reauthenticate') && method == 'GET') { + return _handleReauthenticate(); + } + + // Simulate resend + if (url.contains('/resend') && method == 'POST') { + return _handleResend(requestBody); + } + + // Default response for unhandled requests + return StreamedResponse( + Stream.value( + utf8.encode(jsonEncode({'error': 'Unhandled mock request'}))), + 501, + request: request, + ); + } + + StreamedResponse _handleOtpRequest(Map? requestBody) { + // Check if it's a phone OTP request + if (requestBody?['phone'] != null) { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'message': 'OTP sent to phone', + 'message_id': 'mock-message-id-phone', + }))), + 200, + request: null, + ); + } + + // Check if it's an email OTP request + if (requestBody?['email'] != null) { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'message': 'OTP sent to email', + 'message_id': 'mock-message-id-email', + }))), + 200, + request: null, + ); + } + + // Invalid OTP request + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'error': 'Invalid OTP request', + 'message': 'Email or phone number required', + }))), + 400, + request: null, + ); + } + + StreamedResponse _handleVerifyRequest(Map? requestBody) { + final now = DateTime.now().toIso8601String(); + + // OTP token verification response + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'access_token': accessToken, + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': refreshToken, + 'user': { + 'id': userId, + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': requestBody?['email'] ?? email, + 'phone': requestBody?['phone'] ?? phoneNumber, + 'phone_confirmed_at': now, + 'confirmed_at': now, + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + 'app_metadata': { + 'provider': requestBody?['email'] != null ? 'email' : 'phone', + 'providers': [requestBody?['email'] != null ? 'email' : 'phone'], + }, + 'user_metadata': {}, + 'identities': [ + { + 'id': userId, + 'user_id': userId, + 'identity_data': { + 'sub': userId, + 'email': requestBody?['email'] ?? email, + 'phone': requestBody?['phone'] ?? phoneNumber, + }, + 'provider': requestBody?['email'] != null ? 'email' : 'phone', + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + } + ], + } + }))), + 200, + request: null, + ); + } + + StreamedResponse _handlePhoneSignup(Map? requestBody) { + final now = DateTime.now().toIso8601String(); + + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'access_token': accessToken, + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': refreshToken, + 'user': { + 'id': userId, + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': null, + 'phone': requestBody?['phone'] ?? phoneNumber, + 'phone_confirmed_at': now, + 'confirmed_at': now, + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + 'app_metadata': { + 'provider': 'phone', + 'providers': ['phone'], + }, + 'user_metadata': requestBody?['data'] ?? {}, + 'identities': [ + { + 'id': userId, + 'user_id': userId, + 'identity_data': { + 'sub': userId, + 'phone': requestBody?['phone'] ?? phoneNumber, + }, + 'provider': 'phone', + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + } + ], + } + }))), + 200, + request: null, + ); + } + + StreamedResponse _handlePhoneSignInWithPassword( + Map? requestBody) { + final now = DateTime.now().toIso8601String(); + + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'access_token': accessToken, + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': refreshToken, + 'user': { + 'id': userId, + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': null, + 'phone': requestBody?['phone'] ?? phoneNumber, + 'phone_confirmed_at': now, + 'confirmed_at': now, + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + 'app_metadata': { + 'provider': 'phone', + 'providers': ['phone'], + }, + 'user_metadata': {}, + 'identities': [ + { + 'id': userId, + 'user_id': userId, + 'identity_data': { + 'sub': userId, + 'phone': requestBody?['phone'] ?? phoneNumber, + }, + 'provider': 'phone', + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + } + ], + } + }))), + 200, + request: null, + ); + } + + StreamedResponse _handleReauthenticate() { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'message': 'Reauthentication succeeded', + }))), + 200, + request: null, + ); + } + + StreamedResponse _handleResend(Map? requestBody) { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'message': 'OTP resent', + 'message_id': 'mock-message-id-resend', + }))), + 200, + request: null, + ); + } +} + +/// A mock HTTP client that captures the channel parameter for testing +class ChannelMockClient extends BaseClient { + String? lastChannelUsed; + Map? lastRequestBody; + + @override + Future send(BaseRequest request) async { + if (request is Request) { + try { + lastRequestBody = json.decode(request.body) as Map; + lastChannelUsed = lastRequestBody?['channel']; + } catch (_) { + // If body is not valid JSON, ignore the error + } + } + + final url = request.url.toString(); + final method = request.method; + + // Special handling for verify OTP endpoint + if (url.contains('/verify') && method == 'POST') { + final now = DateTime.now().toIso8601String(); + + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'access_token': 'mock-access-token', + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': 'mock-refresh-token', + 'user': { + 'id': 'mock-user-id', + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': 'test@example.com', + 'phone': '+11234567890', + 'phone_confirmed_at': now, + 'confirmed_at': now, + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + 'app_metadata': { + 'provider': 'phone', + 'providers': ['phone'], + }, + 'user_metadata': {}, + 'identities': [], + } + }))), + 200, + request: request, + ); + } + + // Default response for other requests + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'message': 'OTP sent', + 'message_id': 'mock-message-id', + }))), + 200, + request: request, + ); + } +} + +class ErrorMockClient extends BaseClient { + final int statusCode; + final Map errorResponse; + + ErrorMockClient({ + this.statusCode = 400, + this.errorResponse = const { + 'error': 'Invalid OTP', + 'message': 'The OTP provided is invalid or has expired', + }, + }); + + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode(errorResponse))), + statusCode, + request: request, + ); + } +} + +/// Client that returns empty arrays for certain keys to test null-safety +class EmptyResponseClient extends BaseClient { + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({}))), + 200, + request: request, + ); + } +} + +/// Client that returns specific error codes based on the request +class ConditionalErrorClient extends BaseClient { + @override + Future send(BaseRequest request) async { + final url = request.url.toString(); + final method = request.method; + + if (url.contains('/verify') && method == 'POST') { + // Simulate expired OTP + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'error': 'expired_token', + 'message': 'The OTP has expired', + }))), + 401, + request: request, + ); + } + + if (url.contains('/token') && method == 'POST') { + // Simulate wrong password + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'error': 'invalid_grant', + 'message': 'Invalid login credentials', + }))), + 401, + request: request, + ); + } + + if (url.contains('/signup') && method == 'POST') { + Map? body; + if (request is Request) { + try { + body = json.decode(request.body) as Map; + } catch (_) {} + } + + if (body?['phone'] != null) { + // Simulate phone number already exists + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'error': 'phone_taken', + 'message': 'Phone number is already registered', + }))), + 400, + request: request, + ); + } + } + + // Simulate unknown error for other requests + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'error': 'internal_error', + 'message': 'An unknown error occurred', + }))), + 500, + request: request, + ); + } +} + +/// Custom HTTP client for testing server errors +class CustomServerErrorClient extends BaseClient { + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode('')), + 500, + request: request, + ); + } +} + +/// Custom HTTP client for testing null session +class NullSessionClient extends BaseClient { + final String email; + + NullSessionClient(this.email); + + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'user': { + 'id': 'user-id', + 'email': email, + }, + }))), + 200, + request: request, + ); + } +} diff --git a/packages/gotrue/test/otp_mock_test.dart b/packages/gotrue/test/otp_mock_test.dart new file mode 100644 index 00000000..946ee0ad --- /dev/null +++ b/packages/gotrue/test/otp_mock_test.dart @@ -0,0 +1,604 @@ +import 'package:gotrue/gotrue.dart'; +import 'package:test/test.dart'; + +import 'mocks/otp_mock_client.dart'; +import 'utils.dart'; + +void main() { + const testPhone = '+11234567890'; + const testEmail = 'test@example.com'; + const testUserId = 'mock-user-id-123'; + const testPassword = 'password123'; + + group('Basic OTP and Phone Authentication', () { + late GoTrueClient client; + late OtpMockClient mockClient; + late TestAsyncStorage asyncStorage; + + setUp(() { + mockClient = OtpMockClient( + phoneNumber: testPhone, + email: testEmail, + userId: testUserId, + ); + + asyncStorage = TestAsyncStorage(); + + client = GoTrueClient( + url: 'https://example.com', + httpClient: mockClient, + asyncStorage: asyncStorage, + ); + }); + + test('signInWithOtp() with phone number', () async { + await client.signInWithOtp(phone: testPhone); + // This test passes if no exceptions are thrown + expect(client.currentSession, + isNull); // No session should be set yet as OTP is not verified + }); + + test('signInWithOtp() with email', () async { + await client.signInWithOtp(email: testEmail); + // This test passes if no exceptions are thrown + expect(client.currentSession, + isNull); // No session should be set yet as OTP is not verified + }); + + test('signInWithOtp() with phone number and custom data', () async { + await client.signInWithOtp( + phone: testPhone, + shouldCreateUser: true, + data: {'name': 'Test User'}, + ); + // This test passes if no exceptions are thrown + expect(client.currentSession, + isNull); // No session should be set yet as OTP is not verified + }); + + test('signInWithOtp() without email or phone should throw', () async { + try { + await client.signInWithOtp(); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message, + contains('You must provide either an email, phone number')); + } + }); + + test('verifyOTP() with phone number', () async { + final response = await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + expect(response.user?.phone, testPhone); + + // Verify session was set + expect(client.currentSession, isNotNull); + expect(client.currentUser, isNotNull); + expect(client.currentUser?.phone, testPhone); + }); + + test('verifyOTP() with email', () async { + final response = await client.verifyOTP( + email: testEmail, + token: '123456', + type: OtpType.email, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + expect(response.user?.email, testEmail); + + // Verify session was set + expect(client.currentSession, isNotNull); + expect(client.currentUser, isNotNull); + expect(client.currentUser?.email, testEmail); + }); + + test('verifyOTP() with recovery type', () async { + final response = await client.verifyOTP( + email: testEmail, + token: '123456', + type: OtpType.recovery, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + + // Verify session was set + expect(client.currentSession, isNotNull); + expect(client.currentUser, isNotNull); + }); + + test('verifyOTP() with tokenHash', () async { + final response = await client.verifyOTP( + tokenHash: 'mock-token-hash', + token: '123456', + type: OtpType.email, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + + // Verify session was set + expect(client.currentSession, isNotNull); + expect(client.currentUser, isNotNull); + }); + + test('verifyOTP() without token should throw', () async { + try { + await client.verifyOTP( + email: testEmail, + type: OtpType.email, + ); + fail('Should have thrown an exception'); + } catch (e) { + expect(e, isA()); + } + }); + + test('signUp() with phone number', () async { + final response = await client.signUp( + phone: testPhone, + password: testPassword, + data: {'name': 'Test User'}, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + expect(response.user?.phone, testPhone); + expect(response.user?.userMetadata?['name'], 'Test User'); + + // Verify session was set + expect(client.currentSession, isNotNull); + expect(client.currentUser, isNotNull); + expect(client.currentUser?.phone, testPhone); + }); + + test('signUp() with phone number and specific channel', () async { + final response = await client.signUp( + phone: testPhone, + password: testPassword, + channel: OtpChannel.whatsapp, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + }); + + test('signInWithPassword() with phone number', () async { + final response = await client.signInWithPassword( + phone: testPhone, + password: testPassword, + ); + + expect(response.session, isNotNull); + expect(response.user, isNotNull); + expect(response.user?.phone, testPhone); + + // Verify session was set + expect(client.currentSession, isNotNull); + expect(client.currentUser, isNotNull); + expect(client.currentUser?.phone, testPhone); + }); + + test('reauthenticate() works correctly', () async { + // First sign in to set the session + await client.signInWithPassword( + phone: testPhone, + password: testPassword, + ); + + // Then reauthenticate + await client.reauthenticate(); + // This test passes if no exceptions are thrown + }); + + test('reauthenticate() throws when no session', () async { + try { + await client.reauthenticate(); + fail('Should have thrown an exception'); + } on AuthSessionMissingException catch (_) { + // Expected exception + } + }); + + test('resend() with phone type', () async { + final response = await client.resend( + phone: testPhone, + type: OtpType.sms, + ); + + expect(response, isA()); + }); + + test('resend() with email type', () async { + final response = await client.resend( + email: testEmail, + type: OtpType.signup, + ); + + expect(response, isA()); + }); + + test('resend() with phone_change type', () async { + final response = await client.resend( + phone: testPhone, + type: OtpType.phoneChange, + ); + + expect(response, isA()); + }); + + test('resend() with email_change type', () async { + final response = await client.resend( + email: testEmail, + type: OtpType.emailChange, + ); + + expect(response, isA()); + }); + + test('resend() with wrong type for phone throws', () async { + try { + await client.resend( + phone: testPhone, + type: OtpType.signup, // This should be sms or phoneChange for phone + ); + fail('Should have thrown an exception'); + } catch (e) { + expect(e, isA()); + } + }); + + test('resend() with wrong type for email throws', () async { + try { + await client.resend( + email: testEmail, + type: OtpType.sms, // This should be signup or emailChange for email + ); + fail('Should have thrown an exception'); + } catch (e) { + expect(e, isA()); + } + }); + + test('signInWithOtp() with different channel types', () async { + // Test WhatsApp channel + await client.signInWithOtp( + phone: testPhone, + channel: OtpChannel.whatsapp, + ); + + // Test SMS channel (default) + await client.signInWithOtp( + phone: testPhone, + channel: OtpChannel.sms, + ); + }); + }); + + group('OTP Edge Cases and Error Conditions', () { + test('verifyOTP() with invalid OTP throws AuthException', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: ErrorMockClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message, 'The OTP provided is invalid or has expired'); + } + }); + + test( + 'signInWithPassword() with phone and wrong password throws AuthException', + () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: ConditionalErrorClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.signInWithPassword( + phone: testPhone, + password: 'wrong-password', + ); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message, 'Invalid login credentials'); + } + }); + + test('signUp() with existing phone number throws AuthException', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: ConditionalErrorClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.signUp( + phone: testPhone, + password: testPassword, + ); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message, 'Phone number is already registered'); + } + }); + + test('signInWithOtp() with empty response', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: EmptyResponseClient(), + asyncStorage: TestAsyncStorage(), + ); + + // Should not throw an exception + await client.signInWithOtp(phone: testPhone); + }); + + test('verifyOTP() with neither email nor phone throws assertion error', + () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: EmptyResponseClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.verifyOTP( + token: '123456', + type: OtpType.sms, + ); + fail('Should have thrown an exception'); + } catch (e) { + expect(e, isA()); + } + }); + + test('verifyOTP() with expired token throws AuthException', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: ConditionalErrorClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message, 'The OTP has expired'); + } + }); + + test('resend() with both email and phone throws assertion error', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: EmptyResponseClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.resend( + email: testEmail, + phone: testPhone, + type: OtpType.sms, + ); + fail('Should have thrown an exception'); + } catch (e) { + expect(e, isA()); + } + }); + + test('signUp() without email or phone throws exception', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: EmptyResponseClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.signUp(password: testPassword); + fail('Should have thrown an exception'); + } catch (e) { + expect(e, isA()); + } + }); + + test('signInWithPassword() without email or phone throws exception', + () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: EmptyResponseClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.signInWithPassword(password: testPassword); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message.contains('You must provide either an'), isTrue); + } + }); + + test('empty response on server error', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: CustomServerErrorClient(), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.signInWithOtp(phone: testPhone); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.statusCode, '500'); + } + }); + + test('response with null session', () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: NullSessionClient(testEmail), + asyncStorage: TestAsyncStorage(), + ); + + try { + await client.verifyOTP( + email: testEmail, + token: '123456', + type: OtpType.email, + ); + fail('Should have thrown an exception'); + } on AuthException catch (e) { + expect(e.message, 'An error occurred on token verification.'); + } + }); + }); + + group('Channel Types Tests', () { + late GoTrueClient client; + late ChannelMockClient mockClient; + late TestAsyncStorage asyncStorage; + + setUp(() { + mockClient = ChannelMockClient(); + asyncStorage = TestAsyncStorage(); + + client = GoTrueClient( + url: 'https://example.com', + httpClient: mockClient, + asyncStorage: asyncStorage, + ); + }); + + test('signInWithOtp() uses sms channel by default', () async { + await client.signInWithOtp(phone: testPhone); + expect(mockClient.lastChannelUsed, 'sms'); + }); + + test('signInWithOtp() with whatsapp channel', () async { + await client.signInWithOtp( + phone: testPhone, + channel: OtpChannel.whatsapp, + ); + expect(mockClient.lastChannelUsed, 'whatsapp'); + }); + + test('signUp() with whatsapp channel', () async { + await client.signUp( + phone: testPhone, + password: testPassword, + channel: OtpChannel.whatsapp, + ); + expect(mockClient.lastChannelUsed, 'whatsapp'); + }); + + test('signUp() uses sms channel by default', () async { + await client.signUp( + phone: testPhone, + password: testPassword, + ); + expect(mockClient.lastChannelUsed, 'sms'); + }); + + test('resend() with sms type sets channel to sms', () async { + await client.resend( + phone: testPhone, + type: OtpType.sms, + ); + expect(mockClient.lastRequestBody?['type'], 'sms'); + }); + + test('resend() with phone_change type sets correct type', () async { + await client.resend( + phone: testPhone, + type: OtpType.phoneChange, + ); + expect(mockClient.lastRequestBody?['type'], 'phone_change'); + }); + + test('OtpChannel enum converts to correct string values', () { + // Test enum conversion to string + expect(OtpChannel.sms.name, 'sms'); + expect(OtpChannel.whatsapp.name, 'whatsapp'); + + // Test that the enum is used correctly in the request + client.signInWithOtp( + phone: testPhone, + channel: OtpChannel.whatsapp, + ); + expect(mockClient.lastChannelUsed, 'whatsapp'); + + client.signInWithOtp( + phone: testPhone, + channel: OtpChannel.sms, + ); + expect(mockClient.lastChannelUsed, 'sms'); + }); + + test('OtpType enum is used correctly in requests', () async { + // Test enum representation in API calls + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + expect(mockClient.lastRequestBody?['type'], 'sms'); + + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.recovery, + ); + expect(mockClient.lastRequestBody?['type'], 'recovery'); + + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.signup, + ); + expect(mockClient.lastRequestBody?['type'], 'signup'); + + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.invite, + ); + expect(mockClient.lastRequestBody?['type'], 'invite'); + + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.phoneChange, + ); + expect(mockClient.lastRequestBody?['type'], 'phone_change'); + + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.emailChange, + ); + expect(mockClient.lastRequestBody?['type'], 'email_change'); + }); + }); +} diff --git a/packages/postgrest/lib/src/types.dart b/packages/postgrest/lib/src/types.dart index 4761a9ea..2e091719 100644 --- a/packages/postgrest/lib/src/types.dart +++ b/packages/postgrest/lib/src/types.dart @@ -88,11 +88,14 @@ enum CountOption { estimated, } +// coverage:ignore-[start] /// Returns count as part of the response when specified. +@Deprecated('Not used anywhere. Will be removed in the next major version.') enum ReturningOption { minimal, representation, } +// coverage:ignore-[end] /// The type of tsquery conversion to use on [query]. enum TextSearchType { diff --git a/packages/storage_client/lib/src/file_stub.dart b/packages/storage_client/lib/src/file_stub.dart index e19f8282..55143457 100644 --- a/packages/storage_client/lib/src/file_stub.dart +++ b/packages/storage_client/lib/src/file_stub.dart @@ -1,2 +1,3 @@ +// coverage:ignore-file /// A stub for the `dart:io` [File] class. typedef File = dynamic; diff --git a/packages/supabase_flutter/lib/src/local_storage_stub.dart b/packages/supabase_flutter/lib/src/local_storage_stub.dart index 253ae461..10e50136 100644 --- a/packages/supabase_flutter/lib/src/local_storage_stub.dart +++ b/packages/supabase_flutter/lib/src/local_storage_stub.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file Future hasAccessToken(_) => throw UnimplementedError(); Future accessToken(_) async => throw UnimplementedError(); diff --git a/packages/supabase_flutter/test/supabase_flutter_test.dart b/packages/supabase_flutter/test/supabase_flutter_test.dart index 920c2dbe..9205e77b 100644 --- a/packages/supabase_flutter/test/supabase_flutter_test.dart +++ b/packages/supabase_flutter/test/supabase_flutter_test.dart @@ -172,4 +172,79 @@ void main() { expect(pkceHttpClient.lastRequestBody['auth_code'], 'my-code-verifier'); }); }); + group('EmptyLocalStorage', () { + late EmptyLocalStorage localStorage; + + setUp(() async { + mockAppLink(); + + localStorage = const EmptyLocalStorage(); + // Initialize the Supabase singleton + await Supabase.initialize( + url: supabaseUrl, + anonKey: supabaseKey, + debug: false, + authOptions: FlutterAuthClientOptions( + localStorage: localStorage, + pkceAsyncStorage: MockAsyncStorage(), + ), + ); + }); + + test('initialize does nothing', () async { + // Should not throw any exceptions + await localStorage.initialize(); + }); + + test('hasAccessToken returns false', () async { + final result = await localStorage.hasAccessToken(); + expect(result, false); + }); + + test('accessToken returns null', () async { + final result = await localStorage.accessToken(); + expect(result, null); + }); + + test('removePersistedSession does nothing', () async { + // Should not throw any exceptions + await localStorage.removePersistedSession(); + }); + + test('persistSession does nothing', () async { + // Should not throw any exceptions + await localStorage.persistSession('test-session-string'); + }); + + test('all methods work together in a typical flow', () async { + // Initialize the storage + await localStorage.initialize(); + + // Check if there's a token (should be false) + final hasToken = await localStorage.hasAccessToken(); + expect(hasToken, false); + + // Get the token (should be null) + final token = await localStorage.accessToken(); + expect(token, null); + + // Try to persist a session + await localStorage.persistSession('test-session-data'); + + // Check if there's a token after persisting (should still be false) + final hasTokenAfterPersist = await localStorage.hasAccessToken(); + expect(hasTokenAfterPersist, false); + + // Get the token after persisting (should still be null) + final tokenAfterPersist = await localStorage.accessToken(); + expect(tokenAfterPersist, null); + + // Try to remove the session + await localStorage.removePersistedSession(); + + // Check if there's a token after removing (should still be false) + final hasTokenAfterRemove = await localStorage.hasAccessToken(); + expect(hasTokenAfterRemove, false); + }); + }); }