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
@@ -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, '网络错误,请稍后重试');
});
}