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:
qzl
2026-03-19 18:42:05 +08:00
parent 636b37ee5a
commit 0661016827
29 changed files with 615 additions and 2030 deletions
@@ -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, '网络错误,请稍后重试');
});
}