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:
@@ -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