feat(client): P1 真实数据接入地基(#6 6A)
- api_config.dart:API 基址单源(消除各处重复 _kApiUrl) - api_client.dart:统一鉴权 HTTP 客户端,401→AuthNotifier.refresh→重试一次, 错误统一 AuthApiException;+ 单测覆盖 401 刷新重试 - 模型 me/device/plan/usage_point(对齐后端 snake_case 契约) - account_api.dart:/v1/me、/v1/plans、/v1/me/devices(列表+删)、/v1/usage、 /v1/redeem、/v1/ads/unlock 封装 - auth_provider:注入 AuthApi + refresh();account_providers 装配 ApiClient/AccountApi - 顺带修复 onboarding 提交遗留的测试 stub(_NullTokenStore 缺 isOnboarded/markOnboarded) flutter analyze 0 error;114 tests passed。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// api_client_test.dart — ApiClient 鉴权 + 401 自动刷新重试。
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||
|
||||
void main() {
|
||||
group('ApiClient', () {
|
||||
test('GET 附带 Bearer 并解析 JSON', () async {
|
||||
late String seenAuth;
|
||||
final c = ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => 'tok1',
|
||||
refresh: () async => false,
|
||||
client: MockClient((req) async {
|
||||
seenAuth = req.headers['Authorization'] ?? '';
|
||||
return http.Response('{"email":"a@b.c"}', 200);
|
||||
}),
|
||||
);
|
||||
final body = await c.getJson('/v1/me');
|
||||
expect(seenAuth, 'Bearer tok1');
|
||||
expect(body['email'], 'a@b.c');
|
||||
});
|
||||
|
||||
test('401 → refresh 成功 → 用新令牌重试一次', () async {
|
||||
var token = 'old';
|
||||
var calls = 0;
|
||||
final c = ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => token,
|
||||
refresh: () async {
|
||||
token = 'new';
|
||||
return true;
|
||||
},
|
||||
client: MockClient((req) async {
|
||||
calls++;
|
||||
if (req.headers['Authorization'] == 'Bearer old') {
|
||||
return http.Response('{"message_zh":"expired"}', 401);
|
||||
}
|
||||
return http.Response('{"ok":true}', 200);
|
||||
}),
|
||||
);
|
||||
final body = await c.getJson('/v1/me');
|
||||
expect(calls, 2); // 首次 401 + 刷新后重试
|
||||
expect(body['ok'], true);
|
||||
});
|
||||
|
||||
test('401 → refresh 失败 → 抛 AuthApiException(401)', () async {
|
||||
final c = ApiClient(
|
||||
baseUrl: 'http://x',
|
||||
getToken: () => 'old',
|
||||
refresh: () async => false,
|
||||
client: MockClient((req) async => http.Response('{"message_zh":"expired"}', 401)),
|
||||
);
|
||||
expect(
|
||||
() => c.getJson('/v1/me'),
|
||||
throwsA(isA<AuthApiException>().having((e) => e.statusCode, 'status', 401)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,10 @@ class _NullTokenStore implements TokenStore {
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,10 @@ class _NullTokenStore implements TokenStore {
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
}
|
||||
|
||||
// ── 辅助:带 stub 覆盖的 ProviderContainer ──────────────────────
|
||||
|
||||
Reference in New Issue
Block a user