// AuthBloc 单元测试 // // 覆盖:App启动恢复、登录/注册、角色选择、班级码加入、登出、认证过期 // 使用 mocktail 手动 mock AuthRepository 和 ClassRepository import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:nuanji_app/data/models/auth_token.dart'; import 'package:nuanji_app/data/models/school_class.dart'; import 'package:nuanji_app/data/models/user.dart'; import 'package:nuanji_app/data/remote/api_client.dart'; import 'package:nuanji_app/data/repositories/auth_repository.dart'; import 'package:nuanji_app/data/repositories/class_repository.dart'; import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart'; // ===== Mock 类 ===== class MockAuthRepository extends Mock implements AuthRepository {} class MockClassRepository extends Mock implements ClassRepository {} // ===== 测试数据 ===== const _testUser = User( id: 'u-001', username: 'testuser', displayName: '测试用户', ); const _testUserWithRole = User( id: 'u-002', username: 'roleduser', displayName: '有角色用户', roles: [ UserRole( id: 'r-001', name: '学生', code: 'student', ), ], ); final _testSchoolClass = SchoolClass( id: 'c-001', name: '三年级一班', schoolName: '阳光小学', teacherId: 't-001', classCode: 'ABC123', createdAt: DateTime(2026, 1, 1), updatedAt: DateTime(2026, 1, 1), ); // TokenRefreshed 测试用 — DateTime 不是编译期常量,因此用 final final _testToken = AuthToken( accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, expiresAt: DateTime.parse('2099-12-31T23:59:59Z'), ); void main() { late MockAuthRepository mockAuthRepo; late MockClassRepository mockClassRepo; late AuthBloc bloc; setUpAll(() { // 为 mocktail 的 any() 匹配器注册 String fallback 值 registerFallbackValue(''); }); setUp(() { mockAuthRepo = MockAuthRepository(); mockClassRepo = MockClassRepository(); bloc = AuthBloc( authRepository: mockAuthRepo, classRepository: mockClassRepo, ); }); tearDown(() { bloc.close(); }); // ===== 辅助 ===== /// 派发事件并等待 BLoC 处理完毕,返回最终状态 Future dispatch(AuthEvent event) async { bloc.add(event); await Future.delayed(const Duration(milliseconds: 50)); return bloc.state; } /// 先将 BLoC 推入 Authenticated 状态,方便测试角色/班级码等后续流程 Future seedAuthenticated({ User user = _testUser, bool needsRoleSelection = false, bool needsClassCode = false, }) async { bloc.emit(Authenticated( user: user, needsRoleSelection: needsRoleSelection, needsClassCode: needsClassCode, )); await Future.delayed(const Duration(milliseconds: 10)); } // ===== AppStarted ===== group('AppStarted', () { test('有本地用户 → Authenticated', () async { // arrange when(() => mockAuthRepo.restoreAuth()).thenAnswer((_) async => _testUser); // act final state = await dispatch(const AppStarted()); // assert expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.user.id, equals('u-001')); expect(authenticated.needsRoleSelection, isTrue); // _testUser 没有 roles verify(() => mockAuthRepo.restoreAuth()).called(1); }); test('无本地用户 → Unauthenticated', () async { // arrange when(() => mockAuthRepo.restoreAuth()).thenAnswer((_) async => null); // act final state = await dispatch(const AppStarted()); // assert expect(state, isA()); verify(() => mockAuthRepo.restoreAuth()).called(1); }); test('restoreAuth 异常 → Unauthenticated', () async { // arrange when(() => mockAuthRepo.restoreAuth()).thenThrow(Exception('存储损坏')); // act final state = await dispatch(const AppStarted()); // assert — 异常不冒泡,而是降级为未认证 expect(state, isA()); verify(() => mockAuthRepo.restoreAuth()).called(1); }); }); // ===== LoginRequested ===== group('LoginRequested', () { test('成功 → Authenticating → Authenticated', () async { // arrange when(() => mockAuthRepo.login( username: any(named: 'username'), password: any(named: 'password'), )).thenAnswer((_) async => _testUserWithRole); // act final state = await dispatch( const LoginRequested(username: 'testuser', password: 'pass123'), ); // assert — 有角色的用户,needsRoleSelection 为 false expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.user.id, equals('u-002')); expect(authenticated.needsRoleSelection, isFalse); verify(() => mockAuthRepo.login( username: 'testuser', password: 'pass123', )).called(1); }); test('AuthException → AuthError', () async { // arrange when(() => mockAuthRepo.login( username: any(named: 'username'), password: any(named: 'password'), )).thenThrow(const AuthException('用户名或密码错误')); // act final state = await dispatch( const LoginRequested(username: 'bad', password: 'wrong'), ); // assert expect(state, isA()); final error = state as AuthError; expect(error.message, equals('用户名或密码错误')); expect(error.retryable, isTrue); // 默认可重试 }); test('OfflineException → AuthError(retryable: true)', () async { // arrange when(() => mockAuthRepo.login( username: any(named: 'username'), password: any(named: 'password'), )).thenThrow(const OfflineException()); // act final state = await dispatch( const LoginRequested(username: 'user', password: 'pass'), ); // assert expect(state, isA()); final error = state as AuthError; expect(error.message, contains('网络')); expect(error.retryable, isTrue); }); }); // ===== RegisterRequested ===== group('RegisterRequested', () { test('成功 → Authenticating(isRegister:true) → Authenticated(needsRoleSelection:true)', () async { // arrange when(() => mockAuthRepo.register( username: any(named: 'username'), password: any(named: 'password'), displayName: any(named: 'displayName'), )).thenAnswer((_) async => _testUser); // act final state = await dispatch( const RegisterRequested( username: 'newuser', password: 'pass123', displayName: '小明', ), ); // assert — 注册后必须选角色 expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.needsRoleSelection, isTrue); verify(() => mockAuthRepo.register( username: 'newuser', password: 'pass123', displayName: '小明', )).called(1); }); }); // ===== RoleSelected ===== group('RoleSelected', () { test('选择 student → needsClassCode:true', () async { // arrange — 从 Authenticated 状态开始 await seedAuthenticated(); // act final state = await dispatch(const RoleSelected(UserRoleType.student)); // assert expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.needsRoleSelection, isFalse); expect(authenticated.needsClassCode, isTrue); }); test('选择 teacher → needsClassCode:false', () async { // arrange await seedAuthenticated(); // act final state = await dispatch(const RoleSelected(UserRoleType.teacher)); // assert expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.needsRoleSelection, isFalse); expect(authenticated.needsClassCode, isFalse); }); test('当前非 Authenticated → 状态不变', () async { // arrange — bloc 初始状态为 AuthInitial expect(bloc.state, isA()); // act final state = await dispatch(const RoleSelected(UserRoleType.student)); // assert — 状态未改变 expect(state, isA()); }); }); // ===== ClassCodeSubmitted ===== group('ClassCodeSubmitted', () { test('成功 → needsClassCode:false', () async { // arrange await seedAuthenticated(needsClassCode: true); when(() => mockClassRepo.joinClass(any(), nickname: any(named: 'nickname'))) .thenAnswer((_) async => _testSchoolClass); // act final state = await dispatch(const ClassCodeSubmitted('ABC123')); // assert expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.needsClassCode, isFalse); expect(authenticated.isLoading, isFalse); expect(authenticated.classCodeError, isNull); verify(() => mockClassRepo.joinClass('ABC123', nickname: '测试用户')).called(1); }); test('429 → classCodeError 包含 "30分钟"', () async { // arrange await seedAuthenticated(needsClassCode: true); // 构造 429 DioException final dioError = DioException( requestOptions: RequestOptions(path: '/diary/classes/join'), response: Response( requestOptions: RequestOptions(path: '/diary/classes/join'), statusCode: 429, ), ); when(() => mockClassRepo.joinClass(any(), nickname: any(named: 'nickname'))) .thenThrow(dioError); // act final state = await dispatch(const ClassCodeSubmitted('BADCODE')); // assert expect(state, isA()); final authenticated = state as Authenticated; expect(authenticated.isLoading, isFalse); expect(authenticated.classCodeError, contains('30 分钟')); expect(authenticated.needsClassCode, isTrue); // 仍然需要班级码 }); }); // ===== LogoutRequested ===== group('LogoutRequested', () { test('登出 → Unauthenticated', () async { // arrange await seedAuthenticated(); when(() => mockAuthRepo.logout()).thenAnswer((_) async {}); // act final state = await dispatch(const LogoutRequested()); // assert expect(state, isA()); verify(() => mockAuthRepo.logout()).called(1); }); test('logout 抛异常 → 仍为 Unauthenticated(错误忽略)', () async { // arrange await seedAuthenticated(); when(() => mockAuthRepo.logout()).thenThrow(Exception('网络错误')); // act final state = await dispatch(const LogoutRequested()); // assert — 即使 logout 失败,状态也变为 Unauthenticated expect(state, isA()); }); }); // ===== AuthExpired ===== group('AuthExpired', () { test('认证过期 → Unauthenticated', () async { // arrange — 从已认证状态开始 await seedAuthenticated(); // act final state = await dispatch(const AuthExpired()); // assert expect(state, isA()); }); }); // ===== TokenRefreshed ===== group('TokenRefreshed', () { test('令牌刷新 → 状态不变', () async { // arrange — 从已认证状态开始 await seedAuthenticated(); final stateBefore = bloc.state; // act final state = await dispatch(TokenRefreshed(_testToken)); // assert — 状态不变 expect(state, equals(stateBefore)); }); }); }