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 setUpWith( Future 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().having( (e) => e.code, 'code', ApiCodes.badCredentials, ), ), ); expect(session.status, AuthStatus.unknown); expect(session.accessToken, isNull); }); test('注册:携带 Idempotency-Key,40900 映射为业务异常', () async { await setUpWith( (options) async => jsonResponse(409, errorEnvelope(40900, '用户名重复')), ); await expectLater( repository.register( username: 'gunnar', phone: '13800138000', password: 'passw0rd', ), throwsA( isA().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())); 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()), ); }); test('HTTP 429 映射为 ApiRateLimitException', () async { await setUpWith( (options) async => jsonResponse(429, errorEnvelope(42900, '限流')), ); await expectLater( repository.login(username: 'gunnar', password: 'passw0rd'), throwsA(isA()), ); }); }