test(contract): 第2刀b Dart 契约快照(stats + /v1 API 字段面冻结)
支柱 2(契约单源)守门 — 堵 mac-stats-0 一类跨进程契约悄悄分叉: - test/contract/stats_contract_test.dart:冻结 pangolin/vpn/stats payload 字段面 + status 四值,改契约即测试红、逼同步原生产出端 + 文档 - test/contract/api_contract_test.dart:冻结 /v1/me、/v1/usage、 /v1/usage/devices 的 snake_case 字段面 - ci.yml:flutter job 测试纳入 test/contract;本地 15 用例全过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ jobs:
|
||||
docker run --rm \
|
||||
-v "$PWD/client:/app" -w /app \
|
||||
ghcr.io/cirruslabs/flutter:stable \
|
||||
bash -c "flutter pub get && flutter analyze && flutter test test/unit test/widget"
|
||||
bash -c "flutter pub get && flutter analyze && flutter test test/unit test/widget test/contract"
|
||||
|
||||
# ── Job 5: Portable SQL Scan (支柱 3:双库可移植)─────────────────────────
|
||||
# server Go 运行时查询不得含 MySQL 专属构造(同一份 SQL 要跑 mysql 与 sqlite)。
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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);
|
||||
});
|
||||
|
||||
test('expires_at 接受别名 expire_at(后端历史兼容)', () {
|
||||
final me = Me.fromJson({
|
||||
..._meSample()..remove('expires_at'),
|
||||
'expire_at': '2027-01-01T00:00:00Z',
|
||||
});
|
||||
expect(me.expiresAt, DateTime.tryParse('2027-01-01T00:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// stats_contract_test.dart — 跨进程契约快照(支柱 2:契约单源)。
|
||||
//
|
||||
// 冻结 pangolin/vpn/stats 与 pangolin/vpn/status 两条 EventChannel 的「字段面」。
|
||||
// 任何字段增删改名 / 状态值变动都会让本测试失败,逼开发者显式更新基线 + 同步另一端。
|
||||
// 这正是「mac 实时统计恒为 0」一类 bug 的守门:原生产出端与 Dart 消费端各写、悄悄
|
||||
// 分叉时,没有任何东西报警。
|
||||
//
|
||||
// ⚠️ 改下面的冻结集 = 改跨进程契约,必须同步三处(否则静默失效):
|
||||
// 1. 原生产出端:client/ios|macos/Runner/StatsClient.swift、
|
||||
// client/android/.../PangolinVpnService.kt、桌面 client/lib/bridge/kernel_process.dart
|
||||
// 2. 消费端:client/lib/bridge/vpn_bridge.dart(VpnStatsEvent.fromMap / VpnStatus)
|
||||
// 3. 文档:vpn_bridge.dart 头注 + docs/dev-conventions.html 支柱 2
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||
|
||||
// ── 契约冻结集 ────────────────────────────────────────────────────
|
||||
const _frozenStatsKeys = <String>{
|
||||
'uploadBytes',
|
||||
'downloadBytes',
|
||||
'uploadSpeed',
|
||||
'downloadSpeed',
|
||||
'urltestResults',
|
||||
};
|
||||
const _frozenUrltestKeys = <String>{'tag', 'delayMs'};
|
||||
const _frozenStatusValues = <String>{'off', 'connecting', 'on', 'error'};
|
||||
|
||||
/// canonical 契约样本 —— 原生侧应当产出的形状(全字段)。
|
||||
Map<String, dynamic> _canonicalStats() => {
|
||||
'uploadBytes': 1024,
|
||||
'downloadBytes': 2048,
|
||||
'uploadSpeed': 128.0,
|
||||
'downloadSpeed': 256.0,
|
||||
'urltestResults': [
|
||||
{'tag': 'auto', 'delayMs': 42},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('stats EventChannel 契约快照', () {
|
||||
test('payload 字段面冻结(增删字段须同步原生 + 文档)', () {
|
||||
expect(_canonicalStats().keys.toSet(), _frozenStatsKeys);
|
||||
final urltest = (_canonicalStats()['urltestResults'] as List).first as Map;
|
||||
expect(urltest.keys.cast<String>().toSet(), _frozenUrltestKeys);
|
||||
});
|
||||
|
||||
test('fromMap 正确解析全部字段与类型', () {
|
||||
final e = VpnStatsEvent.fromMap(_canonicalStats());
|
||||
expect(e.uploadBytes, 1024);
|
||||
expect(e.downloadBytes, 2048);
|
||||
expect(e.uploadSpeed, 128.0);
|
||||
expect(e.downloadSpeed, 256.0);
|
||||
expect(e.urltestResults.single.tag, 'auto');
|
||||
expect(e.urltestResults.single.delayMs, 42);
|
||||
});
|
||||
|
||||
test('整数速率也能解析(原生可能传 int 而非 double)', () {
|
||||
final e = VpnStatsEvent.fromMap({
|
||||
..._canonicalStats(),
|
||||
'uploadSpeed': 128, // num → double
|
||||
'downloadSpeed': 256,
|
||||
});
|
||||
expect(e.uploadSpeed, 128.0);
|
||||
expect(e.downloadSpeed, 256.0);
|
||||
});
|
||||
|
||||
test('缺字段降级为 0/空,不抛(鲁棒性,避免半截 payload 崩溃)', () {
|
||||
final e = VpnStatsEvent.fromMap(const {});
|
||||
expect(e.uploadBytes, 0);
|
||||
expect(e.downloadBytes, 0);
|
||||
expect(e.uploadSpeed, 0.0);
|
||||
expect(e.downloadSpeed, 0.0);
|
||||
expect(e.urltestResults, isEmpty);
|
||||
});
|
||||
|
||||
test('fromNativeMap 接受平台通道原始 Map<Object?,Object?>', () {
|
||||
final Map<Object?, Object?> raw = {
|
||||
'uploadBytes': 10,
|
||||
'downloadBytes': 20,
|
||||
'uploadSpeed': 1.0,
|
||||
'downloadSpeed': 2.0,
|
||||
'urltestResults': const [],
|
||||
};
|
||||
final e = VpnStatsEvent.fromNativeMap(raw);
|
||||
expect(e.uploadBytes, 10);
|
||||
expect(e.downloadBytes, 20);
|
||||
});
|
||||
});
|
||||
|
||||
group('status EventChannel 契约快照', () {
|
||||
test('状态值集合冻结', () {
|
||||
expect(_frozenStatusValues, {'off', 'connecting', 'on', 'error'});
|
||||
});
|
||||
|
||||
test('每个冻结值 round-trip 一致(fromString → toNativeString)', () {
|
||||
for (final s in _frozenStatusValues) {
|
||||
expect(VpnStatus.fromString(s).toNativeString(), s);
|
||||
}
|
||||
});
|
||||
|
||||
test('未知值降级为 error(不静默成 off 误导 UI)', () {
|
||||
expect(VpnStatus.fromString('garbage'), VpnStatus.error);
|
||||
expect(VpnStatus.fromString(''), VpnStatus.error);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user