Files
nj/app/test/features/auth/bloc/auth_bloc_test.dart
iven ffde0c9e77
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
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 通过 (全部通过)
2026-06-01 23:20:18 +08:00

399 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
});
});
}