feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

前端第二批测试 (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:
iven
2026-06-01 23:20:18 +08:00
parent f0921d554c
commit ffde0c9e77
8 changed files with 1460 additions and 0 deletions

View 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));
});
});
}