// api_contract_test.dart — HTTP API 契约快照(支柱 2:契约单源)。 // // 冻结 /v1/me、/v1/usage 响应的「字段面」(snake_case JSON)。 // 字段增删改名会让本测试失败,逼开发者同步后端响应 + 文档,避免控制面与客户端悄悄分叉。 // // ⚠️ 改下面的冻结集 = 改 HTTP 契约,必须同步: // - 后端:server/internal/httpapi/account.go(/v1/me)、server/internal/usage/handler.go(/v1/usage) // - 客户端:client/lib/models/{me,usage_point}.dart // - 文档:各 model 头注 + docs/dev-conventions.html 支柱 2 // 注:/v1/usage/devices(DeviceUsage)契约待 stats-overhaul 合并后补(device_usage.dart 当前 WIP、未提交)。 import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/models/me.dart'; import 'package:pangolin_vpn/models/usage_point.dart'; // ── 契约冻结集(后端 snake_case JSON key)────────────────────────── const _frozenMeKeys = { 'email', 'plan', 'expires_at', 'devices_used', 'devices_max', 'quota_today_min', 'quota_cap_min', 'data_today_gb', 'weekly_gb', 'totp_enabled', }; const _frozenUsagePointKeys = { 'date', '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 'quota_cap_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, }; 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); }); }); }