Files
patbond-flutter/test/features/auth/auth_repository_test.dart
T
lixi 8d890c0424 feat: 落地登录纵切——网络层、认证会话与登录/注册/Splash 三页(ADR-003/ADR-004)
- 新增 lib/core/network/:ApiClient(dio 封装,错误信封→类型化异常,base URL 经 --dart-define=PATBOND_API_BASE_URL 注入)、AuthInterceptor(Bearer + 设备标识)、TokenRefresher(单飞刷新,401/40101 重放一次,仅 40102/再 401 清会话)
- 新增 lib/features/auth/:SessionManager(token 只进 flutter_secure_storage)、AuthRepository(login/register/refresh/logout/me,注册带 Idempotency-Key)、Splash(500ms 最短停留/5s 超时/错误态重试+改用账号登录)、登录页与注册页(照 12 号组装稿,错误三层映射,预留区不渲染)
- App 根组件改为认证状态机驱动(Splash↔登录↔主壳 300ms fade);个人中心退出登录接入真实 logout
- 顺带修复:FIX-1 促销卡渐变改 [primaryStrong, primary]、FIX-2 补 helperStyle: muted、README dart format 命令补 --output=none
- 新增依赖:dio ^5.11.1、flutter_secure_storage ^11.0.0、uuid ^4.6.0
- 门禁:dart format(0 changed)/ flutter analyze(No issues)/ flutter test(30 passed,其中新增 23:TokenRefresher 5 + AuthRepository 10 + 登录页 4 + 注册页 4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 11:52:51 +08:00

217 lines
6.7 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.
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/features/auth/auth_repository.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import '../../helpers/auth_test_helpers.dart';
void main() {
late SessionManager session;
late FakeHttpAdapter adapter;
late ApiAuthRepository repository;
Future<void> setUpWith(
Future<ResponseBody> Function(RequestOptions) handler, {
bool seedTokens = false,
}) async {
session = SessionManager(store: InMemoryTokenStore());
if (seedTokens) {
await session.updateTokens(
sampleTokens(access: 'old-access', refresh: 'old-refresh'),
);
}
final dio = buildPatbondDio(session: session, baseUrl: 'http://test.local');
adapter = FakeHttpAdapter(handler);
dio.httpClientAdapter = adapter;
final refresher = TokenRefresher(dio: dio, session: session);
repository = ApiAuthRepository(
api: ApiClient(dio: dio, session: session, refresher: refresher),
session: session,
refresher: refresher,
);
}
test('登录成功:按契约发请求并把 token 存入会话', () async {
await setUpWith(
(options) async => jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'a1', refresh: 'r1')),
),
);
await repository.login(username: 'gunnar', password: 'passw0rd');
final request = adapter.requests.single;
expect(request.path, '/api/v1/auth/login');
expect(request.method, 'POST');
expect(request.data, {'username': 'gunnar', 'password': 'passw0rd'});
expect(session.accessToken, 'a1');
expect(session.refreshToken, 'r1');
expect(session.userId, 'user-1');
expect(session.status, AuthStatus.authenticated);
});
test('登录 40100:抛业务异常且不建立会话', () async {
await setUpWith(
(options) async => jsonResponse(401, errorEnvelope(40100, '用户名或密码错误')),
);
await expectLater(
repository.login(username: 'gunnar', password: 'wrong'),
throwsA(
isA<ApiBusinessException>().having(
(e) => e.code,
'code',
ApiCodes.badCredentials,
),
),
);
expect(session.status, AuthStatus.unknown);
expect(session.accessToken, isNull);
});
test('注册:携带 Idempotency-Key40900 映射为业务异常', () async {
await setUpWith(
(options) async => jsonResponse(409, errorEnvelope(40900, '用户名重复')),
);
await expectLater(
repository.register(
username: 'gunnar',
phone: '13800138000',
password: 'passw0rd',
),
throwsA(
isA<ApiBusinessException>().having(
(e) => e.code,
'code',
ApiCodes.usernameTaken,
),
),
);
final request = adapter.requests.single;
expect(request.path, '/api/v1/auth/register');
expect(request.data, {
'username': 'gunnar',
'phone': '13800138000',
'password': 'passw0rd',
});
expect(request.headers['Idempotency-Key'], isNotEmpty);
});
test('鉴权请求 40101:单飞刷新后重放一次并携带新 token', () async {
await setUpWith(seedTokens: true, (options) async {
if (options.path == '/api/v1/me') {
final auth = options.headers['Authorization'];
if (auth == 'Bearer old-access') {
return jsonResponse(401, errorEnvelope(40101, 'access token 过期'));
}
return jsonResponse(
200,
okEnvelope({
'userId': 'user-1',
'username': 'gunnar',
'phone': '13800138000',
'createdAt': '2026-09-01T10:00:00+08:00',
}),
);
}
return jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'new-access', refresh: 'new-refresh')),
);
});
final profile = await repository.me();
expect(profile.username, 'gunnar');
expect(adapter.requests.map((r) => r.path).toList(), [
'/api/v1/me',
'/api/v1/auth/refresh',
'/api/v1/me',
]);
expect(adapter.requests.last.headers['Authorization'], 'Bearer new-access');
});
test('刷新后重放仍 401:清会话并抛 SessionExpiredException', () async {
await setUpWith(seedTokens: true, (options) async {
if (options.path == '/api/v1/me') {
return jsonResponse(401, errorEnvelope(40101, 'access token 无效'));
}
return jsonResponse(200, okEnvelope(tokenDataJson()));
});
await expectLater(repository.me(), throwsA(isA<SessionExpiredException>()));
expect(session.refreshToken, isNull);
expect(session.status, AuthStatus.unauthenticated);
});
test('登出:服务端网络失败也保证本地会话清除', () async {
await setUpWith(seedTokens: true, (options) async {
throw DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
);
});
session.markAuthenticated();
await repository.logout();
expect(session.accessToken, isNull);
expect(session.refreshToken, isNull);
expect(session.status, AuthStatus.unauthenticated);
});
test('会话恢复:无 refresh token 返回 noSession,不发请求', () async {
await setUpWith(
(options) async => jsonResponse(200, okEnvelope(tokenDataJson())),
);
final result = await repository.restoreSession();
expect(result, SessionRestoreResult.noSession);
expect(adapter.requests, isEmpty);
});
test('会话恢复:有 refresh token 时静默刷新并返回 authenticated', () async {
await setUpWith(
seedTokens: true,
(options) async => jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'a2', refresh: 'r2')),
),
);
final result = await repository.restoreSession();
expect(result, SessionRestoreResult.authenticated);
expect(adapter.requests.single.path, '/api/v1/auth/refresh');
expect(session.accessToken, 'a2');
});
test('5xx 归为系统错误 ApiNetworkException', () async {
await setUpWith(
(options) async => jsonResponse(500, {'error': 'Internal Server Error'}),
);
await expectLater(
repository.login(username: 'gunnar', password: 'passw0rd'),
throwsA(isA<ApiNetworkException>()),
);
});
test('HTTP 429 映射为 ApiRateLimitException', () async {
await setUpWith(
(options) async => jsonResponse(429, errorEnvelope(42900, '限流')),
);
await expectLater(
repository.login(username: 'gunnar', password: 'passw0rd'),
throwsA(isA<ApiRateLimitException>()),
);
});
}