Files
pangolin/client/test/contract/api_contract_test.dart
T
wangjia 64f96fe018 refactor(api): /v1/me 收敛 expire_at → 只发 expires_at(无 web 用户中心)
之前后端同时发 expire_at + expires_at(分别给 app + web 用户中心)。现已无 web 端,
双发是纯冗余,统一只用 expires_at:
- account.go:meResponse 删 ExpireAt 字段 + GetMe handler 不再赋值
- client me.dart:fromJson 删 (expires_at ?? expire_at) 别名回退
- 两端契约快照同步:Go meResponse 冻结集去 expire_at(12→11 key)、删 Dart 别名测试
- 验证:go test httpapi 绿、flutter test contract(14)绿、analyze EXIT 0、e2e 全链路绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:08:50 +08:00

124 lines
3.7 KiB
Dart

// api_contract_test.dart — HTTP API 契约快照(支柱 2:契约单源)。
//
// 冻结 /v1/me、/v1/usage、/v1/usage/devices 三个响应的「字段面」(snake_case JSON)。
// 字段增删改名会让本测试失败,逼开发者同步后端响应 + 文档,避免控制面与客户端悄悄分叉。
//
// ⚠️ 改下面的冻结集 = 改 HTTP 契约,必须同步:
// - 后端:server/internal/httpapi/account.go(/v1/me)、server/internal/usage/handler.go
// (/v1/usage、/v1/usage/devices)
// - 客户端:client/lib/models/{me,usage_point,device_usage}.dart
// - 文档:各 model 头注 + docs/dev-conventions.html 支柱 2
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/models/me.dart';
import 'package:pangolin_vpn/models/usage_point.dart';
import 'package:pangolin_vpn/models/device_usage.dart';
// ── 契约冻结集(后端 snake_case JSON key)──────────────────────────
const _frozenMeKeys = <String>{
'email',
'plan',
'expires_at',
'devices_used',
'devices_max',
'quota_today_min',
'data_today_gb',
'weekly_gb',
'totp_enabled',
};
const _frozenUsagePointKeys = <String>{
'date',
'bytes_up',
'bytes_down',
'minutes_used',
};
const _frozenDeviceUsageKeys = <String>{
'uuid',
'name',
'platform',
'bytes_up',
'bytes_down',
'minutes_used',
};
Map<String, dynamic> _meSample() => {
'email': 'me@example.com',
'plan': 'pro',
'expires_at': '2026-12-31T00:00:00Z',
'devices_used': 2,
'devices_max': 5,
'quota_today_min': null, // pro/team 不限 → null
'data_today_gb': 1.5,
'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5],
'totp_enabled': true,
};
Map<String, dynamic> _usagePointSample() => {
'date': '2026-06-24',
'bytes_up': 1000,
'bytes_down': 2000,
'minutes_used': 12,
};
Map<String, dynamic> _deviceUsageSample() => {
'uuid': 'dev-uuid-1',
'name': 'MacBook',
'platform': 'macos',
'bytes_up': 3000,
'bytes_down': 4000,
'minutes_used': 30,
};
void main() {
group('/v1/me 契约快照', () {
test('字段面冻结(增删字段须同步后端 account.go + 文档)', () {
expect(_meSample().keys.toSet(), _frozenMeKeys);
});
test('fromJson 正确解析全部字段', () {
final me = Me.fromJson(_meSample());
expect(me.email, 'me@example.com');
expect(me.plan, 'pro');
expect(me.isFree, isFalse);
expect(me.expiresAt, DateTime.tryParse('2026-12-31T00:00:00Z'));
expect(me.devicesUsed, 2);
expect(me.devicesMax, 5);
expect(me.quotaTodayMin, isNull);
expect(me.dataTodayGb, 1.5);
expect(me.weeklyGb, hasLength(7));
expect(me.totpEnabled, isTrue);
});
});
group('/v1/usage 契约快照(UsagePoint)', () {
test('字段面冻结', () {
expect(_usagePointSample().keys.toSet(), _frozenUsagePointKeys);
});
test('fromJson 正确解析 + bytesTotal 派生', () {
final p = UsagePoint.fromJson(_usagePointSample());
expect(p.date, '2026-06-24');
expect(p.bytesUp, 1000);
expect(p.bytesDown, 2000);
expect(p.minutesUsed, 12);
expect(p.bytesTotal, 3000);
});
});
group('/v1/usage/devices 契约快照(DeviceUsage)', () {
test('字段面冻结', () {
expect(_deviceUsageSample().keys.toSet(), _frozenDeviceUsageKeys);
});
test('fromJson 正确解析 + 派生', () {
final d = DeviceUsage.fromJson(_deviceUsageSample());
expect(d.uuid, 'dev-uuid-1');
expect(d.name, 'MacBook');
expect(d.platform, 'macos');
expect(d.bytesUp, 3000);
expect(d.bytesDown, 4000);
expect(d.minutesUsed, 30);
expect(d.bytesTotal, 7000);
});
});
}