diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dcbde2c..3316625 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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)。 diff --git a/client/test/contract/api_contract_test.dart b/client/test/contract/api_contract_test.dart new file mode 100644 index 0000000..c1d8742 --- /dev/null +++ b/client/test/contract/api_contract_test.dart @@ -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 = { + 'email', + 'plan', + 'expires_at', + 'devices_used', + 'devices_max', + 'quota_today_min', + 'data_today_gb', + 'weekly_gb', + 'totp_enabled', +}; +const _frozenUsagePointKeys = { + 'date', + 'bytes_up', + 'bytes_down', + 'minutes_used', +}; +const _frozenDeviceUsageKeys = { + 'uuid', + 'name', + 'platform', + 'bytes_up', + 'bytes_down', + 'minutes_used', +}; + +Map _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 _usagePointSample() => { + 'date': '2026-06-24', + 'bytes_up': 1000, + 'bytes_down': 2000, + 'minutes_used': 12, + }; + +Map _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); + }); + }); +} diff --git a/client/test/contract/stats_contract_test.dart b/client/test/contract/stats_contract_test.dart new file mode 100644 index 0000000..873cd98 --- /dev/null +++ b/client/test/contract/stats_contract_test.dart @@ -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 = { + 'uploadBytes', + 'downloadBytes', + 'uploadSpeed', + 'downloadSpeed', + 'urltestResults', +}; +const _frozenUrltestKeys = {'tag', 'delayMs'}; +const _frozenStatusValues = {'off', 'connecting', 'on', 'error'}; + +/// canonical 契约样本 —— 原生侧应当产出的形状(全字段)。 +Map _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().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', () { + final Map 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); + }); + }); +}