feat(auth): transition from email to phone-based OTP authentication
- Replace Email+Password login with Phone+OTP flow - Remove RegisterCubit and registration screens (email verification) - Remove ResetPasswordCubit and reset password screens - Add phone normalization and international dial code support - Update LoginCubit with sendCode/resend cooldown logic - Add new widgets: phone prefix selector, confirm sheet - Update all auth API endpoints: /otp/send, /phone-session - Update form inputs: Email -> Phone with E.164 validation - Update tests for new auth flow
This commit is contained in:
@@ -6,14 +6,11 @@ import 'package:social_app/features/auth/data/models/signup_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/login_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||
import 'package:social_app/core/storage/token_storage.dart';
|
||||
import 'package:social_app/core/api/api_client.dart';
|
||||
|
||||
class MockAuthApi extends Mock implements AuthApi {}
|
||||
|
||||
class MockTokenStorage extends Mock implements TokenStorage {}
|
||||
|
||||
class MockApiClient extends Mock implements ApiClient {}
|
||||
|
||||
void main() {
|
||||
late AuthRepositoryImpl repository;
|
||||
late MockAuthApi mockApi;
|
||||
@@ -23,43 +20,29 @@ void main() {
|
||||
mockApi = MockAuthApi();
|
||||
mockStorage = MockTokenStorage();
|
||||
repository = AuthRepositoryImpl(api: mockApi, tokenStorage: mockStorage);
|
||||
registerFallbackValue(
|
||||
const SignupStartRequest(username: '', email: '', password: ''),
|
||||
);
|
||||
registerFallbackValue(const LoginRequest(email: '', password: ''));
|
||||
registerFallbackValue(const SignupVerifyRequest(email: '', token: ''));
|
||||
registerFallbackValue(const SignupResendRequest(email: ''));
|
||||
registerFallbackValue(const OtpSendRequest(phone: ''));
|
||||
registerFallbackValue(const LoginRequest(phone: '', token: ''));
|
||||
registerFallbackValue(const LogoutRequest(refreshToken: ''));
|
||||
registerFallbackValue(const RefreshRequest(refreshToken: ''));
|
||||
});
|
||||
|
||||
group('AuthRepositoryImpl', () {
|
||||
test('createVerification calls api and returns response', () async {
|
||||
when(() => mockApi.createVerification(any())).thenAnswer(
|
||||
(_) async =>
|
||||
const VerificationCreateResponse(email: 'test@example.com'),
|
||||
);
|
||||
test('sendOtp calls api', () async {
|
||||
when(() => mockApi.sendOtp(any())).thenAnswer((_) async {});
|
||||
|
||||
final result = await repository.createVerification(
|
||||
const SignupStartRequest(
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
),
|
||||
);
|
||||
await repository.sendOtp('+8613812345678');
|
||||
|
||||
expect(result.email, 'test@example.com');
|
||||
verify(() => mockApi.createVerification(any())).called(1);
|
||||
verify(() => mockApi.sendOtp(any())).called(1);
|
||||
});
|
||||
|
||||
test('createSession calls api and saves tokens', () async {
|
||||
when(() => mockApi.createSession(any())).thenAnswer(
|
||||
test('createPhoneSession calls api and saves tokens', () async {
|
||||
when(() => mockApi.createPhoneSession(any())).thenAnswer(
|
||||
(_) async => AuthResponse(
|
||||
accessToken: 'access_token',
|
||||
refreshToken: 'refresh_token',
|
||||
expiresIn: 3600,
|
||||
tokenType: 'bearer',
|
||||
user: const AuthUser(id: '123', email: 'test@example.com'),
|
||||
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||
),
|
||||
);
|
||||
when(
|
||||
@@ -69,8 +52,9 @@ void main() {
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
final result = await repository.createSession(
|
||||
const LoginRequest(email: 'test@example.com', password: 'password123'),
|
||||
final result = await repository.createPhoneSession(
|
||||
phone: '+8613812345678',
|
||||
token: '123456',
|
||||
);
|
||||
|
||||
expect(result.accessToken, 'access_token');
|
||||
@@ -117,7 +101,7 @@ void main() {
|
||||
refreshToken: 'new_refresh',
|
||||
expiresIn: 3600,
|
||||
tokenType: 'bearer',
|
||||
user: const AuthUser(id: '123', email: 'test@example.com'),
|
||||
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||
),
|
||||
);
|
||||
when(
|
||||
|
||||
@@ -4,33 +4,32 @@ import 'package:social_app/features/auth/data/models/login_request.dart';
|
||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||
|
||||
void main() {
|
||||
group('SignupStartRequest', () {
|
||||
test('serializes to JSON', () {
|
||||
final request = SignupStartRequest(
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
);
|
||||
group('OtpSendRequest', () {
|
||||
test('serializes e164 phone to JSON', () {
|
||||
final request = OtpSendRequest(phone: '+14155552671');
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['username'], 'testuser');
|
||||
expect(json['email'], 'test@example.com');
|
||||
expect(json['password'], 'password123');
|
||||
expect(json['phone'], '+14155552671');
|
||||
});
|
||||
|
||||
test('normalizes 00 prefix to plus', () {
|
||||
final request = OtpSendRequest(phone: '0014155552671');
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['phone'], '+14155552671');
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginRequest', () {
|
||||
test('serializes to JSON', () {
|
||||
final request = LoginRequest(
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
);
|
||||
test('serializes e164 to JSON', () {
|
||||
final request = LoginRequest(phone: '+14155552671', token: '123456');
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['email'], 'test@example.com');
|
||||
expect(json['password'], 'password123');
|
||||
expect(json['phone'], '+14155552671');
|
||||
expect(json['token'], '123456');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +40,7 @@ void main() {
|
||||
'refresh_token': 'test_refresh',
|
||||
'expires_in': 3600,
|
||||
'token_type': 'bearer',
|
||||
'user': {'id': '123', 'email': 'test@example.com'},
|
||||
'user': {'id': '123', 'phone': '+8613812345678'},
|
||||
};
|
||||
|
||||
final response = AuthResponse.fromJson(json);
|
||||
@@ -50,7 +49,7 @@ void main() {
|
||||
expect(response.refreshToken, 'test_refresh');
|
||||
expect(response.expiresIn, 3600);
|
||||
expect(response.user.id, '123');
|
||||
expect(response.user.email, 'test@example.com');
|
||||
expect(response.user.phone, '+8613812345678');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ void main() {
|
||||
refreshToken: 'new_refresh',
|
||||
expiresIn: 3600,
|
||||
tokenType: 'bearer',
|
||||
user: const AuthUser(id: '123', email: 'test@example.com'),
|
||||
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||
),
|
||||
);
|
||||
return authBloc;
|
||||
@@ -107,7 +107,7 @@ void main() {
|
||||
build: () => authBloc,
|
||||
act: (bloc) => bloc.add(
|
||||
AuthLoggedIn(
|
||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
||||
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||
),
|
||||
),
|
||||
expect: () => [isA<AuthAuthenticated>()],
|
||||
@@ -120,7 +120,7 @@ void main() {
|
||||
return authBloc;
|
||||
},
|
||||
seed: () => AuthAuthenticated(
|
||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
||||
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||
),
|
||||
act: (bloc) => bloc.add(AuthLoggedOut()),
|
||||
expect: () => [
|
||||
@@ -137,7 +137,7 @@ void main() {
|
||||
return authBloc;
|
||||
},
|
||||
seed: () => AuthAuthenticated(
|
||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
||||
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||
),
|
||||
act: (bloc) => bloc.add(
|
||||
const AuthSessionInvalidated(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -26,17 +27,68 @@ void main() {
|
||||
});
|
||||
|
||||
blocTest<LoginCubit, LoginState>(
|
||||
'emailChanged updates email',
|
||||
'phoneChanged updates phone',
|
||||
build: () => cubit,
|
||||
act: (c) => c.emailChanged('test@example.com'),
|
||||
act: (c) => c.phoneChanged('+8613812345678'),
|
||||
expect: () => [isA<LoginState>()],
|
||||
);
|
||||
|
||||
blocTest<LoginCubit, LoginState>(
|
||||
'passwordChanged updates password',
|
||||
'codeChanged updates code',
|
||||
build: () => cubit,
|
||||
act: (c) => c.passwordChanged('password123'),
|
||||
act: (c) => c.codeChanged('123456'),
|
||||
expect: () => [isA<LoginState>()],
|
||||
);
|
||||
|
||||
test('sendCode success starts 60s cooldown', () {
|
||||
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||
|
||||
fakeAsync((async) {
|
||||
cubit.phoneChanged('13812345678');
|
||||
|
||||
cubit.sendCode();
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(cubit.state.resendCooldownSeconds, 60);
|
||||
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(cubit.state.resendCooldownSeconds, 59);
|
||||
|
||||
async.elapse(const Duration(seconds: 59));
|
||||
expect(cubit.state.resendCooldownSeconds, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('sendCode is blocked during cooldown', () async {
|
||||
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||
cubit.phoneChanged('13812345678');
|
||||
|
||||
final first = await cubit.sendCode();
|
||||
final second = await cubit.sendCode();
|
||||
|
||||
expect(first, isTrue);
|
||||
expect(second, isFalse);
|
||||
verify(() => mockRepository.sendOtp(any())).called(1);
|
||||
});
|
||||
|
||||
test('phone change resets cooldown and code state', () {
|
||||
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||
|
||||
fakeAsync((async) {
|
||||
cubit.phoneChanged('13812345678');
|
||||
cubit.codeChanged('123456');
|
||||
cubit.sendCode();
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(cubit.state.resendCooldownSeconds, 60);
|
||||
expect(cubit.state.codeSent, isTrue);
|
||||
|
||||
cubit.phoneChanged('14155552671');
|
||||
|
||||
expect(cubit.state.resendCooldownSeconds, 0);
|
||||
expect(cubit.state.codeSent, isFalse);
|
||||
expect(cubit.state.code.value, '');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/core/form_inputs/form_inputs.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||
import 'package:social_app/features/auth/data/models/signup_request.dart';
|
||||
import 'package:social_app/features/auth/presentation/cubits/register_cubit.dart';
|
||||
import 'package:social_app/core/api/api_exception.dart';
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
void main() {
|
||||
late RegisterCubit cubit;
|
||||
late MockAuthRepository mockRepository;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockAuthRepository();
|
||||
cubit = RegisterCubit(mockRepository);
|
||||
registerFallbackValue(
|
||||
SignupStartRequest(username: '', email: '', password: ''),
|
||||
);
|
||||
registerFallbackValue(SignupResendRequest(email: ''));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
group('RegisterCubit', () {
|
||||
test('initial state has pure status', () {
|
||||
expect(cubit.state.status, FormzSubmissionStatus.initial);
|
||||
});
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'usernameChanged updates username',
|
||||
build: () => cubit,
|
||||
act: (c) => c.usernameChanged('testuser'),
|
||||
expect: () => [isA<RegisterState>()],
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'emailChanged updates email',
|
||||
build: () => cubit,
|
||||
act: (c) => c.emailChanged('test@example.com'),
|
||||
expect: () => [isA<RegisterState>()],
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'passwordChanged updates password',
|
||||
build: () => cubit,
|
||||
act: (c) => c.passwordChanged('password123'),
|
||||
expect: () => [isA<RegisterState>()],
|
||||
);
|
||||
});
|
||||
|
||||
group('sendCodeSilently', () {
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'sets isSending to true then false on success',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(
|
||||
username: const Username.dirty('testuser'),
|
||||
email: const Email.dirty('test@example.com'),
|
||||
password: const Password.dirty('password123'),
|
||||
),
|
||||
setUp: () {
|
||||
when(() => mockRepository.createVerification(any())).thenAnswer(
|
||||
(_) async => VerificationCreateResponse(email: 'test@example.com'),
|
||||
);
|
||||
},
|
||||
act: (c) => c.sendCodeSilently(),
|
||||
expect: () => [
|
||||
predicate<RegisterState>((state) => state.isSending == true),
|
||||
predicate<RegisterState>(
|
||||
(state) =>
|
||||
state.isSending == false &&
|
||||
state.codeSent == true &&
|
||||
state.pendingEmail == 'test@example.com' &&
|
||||
state.errorMessage == null,
|
||||
),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockRepository.createVerification(any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'restores isSending to false and sets errorMessage and failure status on error',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(
|
||||
username: const Username.dirty('testuser'),
|
||||
email: const Email.dirty('test@example.com'),
|
||||
password: const Password.dirty('password123'),
|
||||
),
|
||||
setUp: () {
|
||||
when(
|
||||
() => mockRepository.createVerification(any()),
|
||||
).thenThrow(ServerException('Network error'));
|
||||
},
|
||||
act: (c) => c.sendCodeSilently(),
|
||||
expect: () => [
|
||||
predicate<RegisterState>((state) => state.isSending == true),
|
||||
predicate<RegisterState>(
|
||||
(state) =>
|
||||
state.isSending == false &&
|
||||
state.errorMessage == 'Network error' &&
|
||||
state.status == FormzSubmissionStatus.failure,
|
||||
),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockRepository.createVerification(any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'does not call createVerification when input is invalid',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(
|
||||
username: const Username.dirty(''),
|
||||
email: const Email.dirty('invalid'),
|
||||
password: const Password.dirty(''),
|
||||
),
|
||||
act: (c) => c.sendCodeSilently(),
|
||||
expect: () => [],
|
||||
verify: (_) {
|
||||
verifyNever(() => mockRepository.createVerification(any()));
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'returns early when isSending is true',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(
|
||||
username: const Username.dirty('testuser'),
|
||||
email: const Email.dirty('test@example.com'),
|
||||
password: const Password.dirty('password123'),
|
||||
isSending: true,
|
||||
),
|
||||
act: (c) => c.sendCodeSilently(),
|
||||
expect: () => [],
|
||||
verify: (_) {
|
||||
verifyNever(() => mockRepository.createVerification(any()));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('resendCode', () {
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'returns false and sets failure status when pendingEmail is null',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(pendingEmail: null),
|
||||
act: (c) => c.resendCode(),
|
||||
expect: () => [
|
||||
isA<RegisterState>()
|
||||
.having((s) => s.status, 'status', FormzSubmissionStatus.failure)
|
||||
.having((s) => s.errorMessage, 'errorMessage', '验证码发送失败,请返回上一步重试'),
|
||||
],
|
||||
verify: (_) {
|
||||
verifyNever(() => mockRepository.resendVerification(any()));
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'returns true and sets status on success',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(pendingEmail: 'test@example.com'),
|
||||
setUp: () {
|
||||
when(
|
||||
() => mockRepository.resendVerification(any()),
|
||||
).thenAnswer((_) async {});
|
||||
},
|
||||
act: (c) => c.resendCode(),
|
||||
expect: () => [
|
||||
isA<RegisterState>().having(
|
||||
(s) => s.status,
|
||||
'status',
|
||||
FormzSubmissionStatus.inProgress,
|
||||
),
|
||||
isA<RegisterState>().having(
|
||||
(s) => s.status,
|
||||
'status',
|
||||
FormzSubmissionStatus.success,
|
||||
),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockRepository.resendVerification(any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<RegisterCubit, RegisterState>(
|
||||
'returns false on error',
|
||||
build: () => cubit,
|
||||
seed: () => RegisterState(pendingEmail: 'test@example.com'),
|
||||
setUp: () {
|
||||
when(
|
||||
() => mockRepository.resendVerification(any()),
|
||||
).thenThrow(ServerException('Network error'));
|
||||
},
|
||||
act: (c) => c.resendCode(),
|
||||
expect: () => [
|
||||
isA<RegisterState>().having(
|
||||
(s) => s.status,
|
||||
'status',
|
||||
FormzSubmissionStatus.inProgress,
|
||||
),
|
||||
isA<RegisterState>().having(
|
||||
(s) => s.status,
|
||||
'status',
|
||||
FormzSubmissionStatus.failure,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:formz/formz.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
||||
import 'package:social_app/features/auth/presentation/cubits/reset_password_cubit.dart';
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
void main() {
|
||||
late ResetPasswordCubit cubit;
|
||||
late MockAuthRepository mockRepository;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockAuthRepository();
|
||||
cubit = ResetPasswordCubit(mockRepository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'sendCode enters countdown immediately and prevents duplicate clicks',
|
||||
() async {
|
||||
final completer = Completer<void>();
|
||||
when(
|
||||
() => mockRepository.requestPasswordReset(any()),
|
||||
).thenAnswer((_) => completer.future);
|
||||
|
||||
cubit.emailChanged('test@example.com');
|
||||
|
||||
final firstRequest = cubit.sendCode();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(cubit.state.status, FormzSubmissionStatus.inProgress);
|
||||
expect(cubit.state.codeSent, isTrue);
|
||||
expect(cubit.state.resendCountdown, 60);
|
||||
|
||||
await cubit.sendCode();
|
||||
verify(
|
||||
() => mockRepository.requestPasswordReset('test@example.com'),
|
||||
).called(1);
|
||||
|
||||
completer.complete();
|
||||
await firstRequest;
|
||||
},
|
||||
);
|
||||
|
||||
test('sendCode failure cancels countdown and restores retry state', () async {
|
||||
when(
|
||||
() => mockRepository.requestPasswordReset(any()),
|
||||
).thenThrow(Exception('network error'));
|
||||
|
||||
cubit.emailChanged('test@example.com');
|
||||
|
||||
await cubit.sendCode();
|
||||
|
||||
expect(cubit.state.status, FormzSubmissionStatus.failure);
|
||||
expect(cubit.state.codeSent, isFalse);
|
||||
expect(cubit.state.resendCountdown, 0);
|
||||
expect(cubit.state.errorMessage, '网络错误,请稍后重试');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user