前端第二批测试 (42 用例): - AuthBloc: 16 用例 (启动恢复/登录/注册/角色选择/班级码/登出) - HomeBloc: 8 用例 (数据加载/今日检测/心情统计/连续天数/离线容错) - CalendarBloc: 10 用例 (月份切换/日期选择/视图模式/状态保持) - MoodBloc: 8 用例 (统计加载/周期切换/API解析/错误处理) 后端 P0 单元测试 (13 用例): - journal_service: 5 用例 (model_to_resp 转换/mood回退/weather回退/tags解析) - sync_service: 8 用例 (冲突收集/DTO构造/序列化roundtrip/非冲突排除) CI/CD: - pr-check.yml: PR 触发 cargo fmt+check+clippy+test + flutter analyze+test - main-merge.yml: main push 触发完整检查 + cargo audit 安全审计 测试统计: 前端 84 通过, 后端 73 通过 (全部通过)
399 lines
12 KiB
Dart
399 lines
12 KiB
Dart
// 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<AuthState> dispatch(AuthEvent event) async {
|
||
bloc.add(event);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
return bloc.state;
|
||
}
|
||
|
||
/// 先将 BLoC 推入 Authenticated 状态,方便测试角色/班级码等后续流程
|
||
Future<void> seedAuthenticated({
|
||
User user = _testUser,
|
||
bool needsRoleSelection = false,
|
||
bool needsClassCode = false,
|
||
}) async {
|
||
bloc.emit(Authenticated(
|
||
user: user,
|
||
needsRoleSelection: needsRoleSelection,
|
||
needsClassCode: needsClassCode,
|
||
));
|
||
await Future<void>.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<Authenticated>());
|
||
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<Unauthenticated>());
|
||
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<Unauthenticated>());
|
||
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<Authenticated>());
|
||
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<AuthError>());
|
||
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<AuthError>());
|
||
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<Authenticated>());
|
||
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<Authenticated>());
|
||
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<Authenticated>());
|
||
final authenticated = state as Authenticated;
|
||
expect(authenticated.needsRoleSelection, isFalse);
|
||
expect(authenticated.needsClassCode, isFalse);
|
||
});
|
||
|
||
test('当前非 Authenticated → 状态不变', () async {
|
||
// arrange — bloc 初始状态为 AuthInitial
|
||
expect(bloc.state, isA<AuthInitial>());
|
||
|
||
// act
|
||
final state = await dispatch(const RoleSelected(UserRoleType.student));
|
||
|
||
// assert — 状态未改变
|
||
expect(state, isA<AuthInitial>());
|
||
});
|
||
});
|
||
|
||
// ===== 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<Authenticated>());
|
||
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<Authenticated>());
|
||
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<Unauthenticated>());
|
||
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<Unauthenticated>());
|
||
});
|
||
});
|
||
|
||
// ===== AuthExpired =====
|
||
|
||
group('AuthExpired', () {
|
||
test('认证过期 → Unauthenticated', () async {
|
||
// arrange — 从已认证状态开始
|
||
await seedAuthenticated();
|
||
|
||
// act
|
||
final state = await dispatch(const AuthExpired());
|
||
|
||
// assert
|
||
expect(state, isA<Unauthenticated>());
|
||
});
|
||
});
|
||
|
||
// ===== TokenRefreshed =====
|
||
|
||
group('TokenRefreshed', () {
|
||
test('令牌刷新 → 状态不变', () async {
|
||
// arrange — 从已认证状态开始
|
||
await seedAuthenticated();
|
||
final stateBefore = bloc.state;
|
||
|
||
// act
|
||
final state = await dispatch(TokenRefreshed(_testToken));
|
||
|
||
// assert — 状态不变
|
||
expect(state, equals(stateBefore));
|
||
});
|
||
});
|
||
}
|