feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
前端第二批测试 (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 通过 (全部通过)
This commit is contained in:
398
app/test/features/auth/bloc/auth_bloc_test.dart
Normal file
398
app/test/features/auth/bloc/auth_bloc_test.dart
Normal file
@@ -0,0 +1,398 @@
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user