diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3316625..8256f13 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -108,3 +108,20 @@ jobs: -v "$PWD:/repo" -w /repo \ node:20 \ bash ci/check-codegen-drift.sh + + # ── Job 7: Go 服务端(build + test:含契约快照 + sqlite 真库,跳过 integration)── + # 此前 server 测试未进 CI;契约快照(支柱 2)等需在此守门。integration 测试走 + # -tags integration(需 docker 起 mysql/redis),不在本 job,由 run_mysql_test.sh 手动跑。 + go-server: + name: Go — build + test + runs-on: nas + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: go build + test (non-integration) + run: | + docker run --rm \ + -v "$PWD/server:/app" -w /app \ + golang:1.25 \ + bash -c "go build ./... && go test ./..." diff --git a/server/internal/httpapi/contract_test.go b/server/internal/httpapi/contract_test.go new file mode 100644 index 0000000..8e64f3a --- /dev/null +++ b/server/internal/httpapi/contract_test.go @@ -0,0 +1,62 @@ +package httpapi + +// contract_test.go — HTTP 契约快照(支柱 2:契约单源)。 +// +// 冻结 GET /v1/me 响应的字段面(JSON key)。后端加/删/改字段会让本测试失败,逼开发者 +// 同步客户端 model + 文档,避免控制面与客户端悄悄分叉。 +// +// ⚠️ 与客户端冻结集对账:client/test/contract/api_contract_test.dart 的 _frozenMeKeys +// (9 个)。后端这里多发 uuid / dp_uuid,且同时发 expire_at + expires_at(别名):客户端 +// 故意忽略 uuid/dp_uuid、把 expire_at 当 expires_at 的回退。改任一端都要回看另一端。 + +import ( + "reflect" + "strings" + "testing" +) + +func jsonTagSet(v any) map[string]struct{} { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + set := map[string]struct{}{} + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.SplitN(tag, ",", 2)[0] + if name != "" { + set[name] = struct{}{} + } + } + return set +} + +func assertFrozenKeys(t *testing.T, name string, got map[string]struct{}, want ...string) { + t.Helper() + wantSet := make(map[string]struct{}, len(want)) + for _, k := range want { + wantSet[k] = struct{}{} + } + for k := range got { + if _, ok := wantSet[k]; !ok { + t.Errorf("%s: 出现未冻结字段 %q —— 改契约需同步另一端(客户端 model)+ 文档", name, k) + } + } + for _, k := range want { + if _, ok := got[k]; !ok { + t.Errorf("%s: 缺失冻结字段 %q", name, k) + } + } +} + +func TestContractMeResponse(t *testing.T) { + assertFrozenKeys(t, "/v1/me", jsonTagSet(meResponse{}), + "uuid", "email", "dp_uuid", "plan", + "expire_at", "expires_at", + "devices_used", "devices_max", + "quota_today_min", "data_today_gb", "weekly_gb", "totp_enabled", + ) +} diff --git a/server/internal/pb/agentv1/contract_test.go b/server/internal/pb/agentv1/contract_test.go new file mode 100644 index 0000000..6b85aac --- /dev/null +++ b/server/internal/pb/agentv1/contract_test.go @@ -0,0 +1,56 @@ +package agentv1 + +// contract_test.go — agent↔控制面契约快照(支柱 2:契约单源)。 +// +// 冻结 UsageEntry 的字段面(agent 上报用量的最小单元)。改字段会让本测试失败,逼同步 +// proto(server/proto/agent/v1/agent.proto)+ agent 上报端 + 控制面解析端 + 文档。 +// dp_uuid 是数据面凭证、绝不等于 user id(控制面侧 dp_uuid→(user_id,device_id) 解析)。 + +import ( + "reflect" + "strings" + "testing" +) + +func jsonTagSet(v any) map[string]struct{} { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + set := map[string]struct{}{} + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.SplitN(tag, ",", 2)[0] + if name != "" { + set[name] = struct{}{} + } + } + return set +} + +func assertFrozenKeys(t *testing.T, name string, got map[string]struct{}, want ...string) { + t.Helper() + wantSet := make(map[string]struct{}, len(want)) + for _, k := range want { + wantSet[k] = struct{}{} + } + for k := range got { + if _, ok := wantSet[k]; !ok { + t.Errorf("%s: 出现未冻结字段 %q —— 改契约需同步 proto + 两端 + 文档", name, k) + } + } + for _, k := range want { + if _, ok := got[k]; !ok { + t.Errorf("%s: 缺失冻结字段 %q", name, k) + } + } +} + +func TestContractUsageEntry(t *testing.T) { + assertFrozenKeys(t, "UsageEntry", jsonTagSet(UsageEntry{}), + "dp_uuid", "bytes_up", "bytes_down", "session_minutes", + ) +} diff --git a/server/internal/usage/contract_test.go b/server/internal/usage/contract_test.go new file mode 100644 index 0000000..9355534 --- /dev/null +++ b/server/internal/usage/contract_test.go @@ -0,0 +1,70 @@ +package usage + +// contract_test.go — HTTP 契约快照(支柱 2:契约单源)。 +// +// 冻结 /v1/usage(UsagePoint)与 /v1/usage/devices(deviceUsageResponse + DeviceUsagePoint) +// 的字段面。改字段会让本测试失败,逼同步客户端 model + 文档。 +// +// ⚠️ 与客户端冻结集对账:client/test/contract/api_contract_test.dart 的 +// _frozenUsagePointKeys / _frozenDeviceUsageKeys。两端必须一致。 + +import ( + "reflect" + "strings" + "testing" +) + +func jsonTagSet(v any) map[string]struct{} { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + set := map[string]struct{}{} + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.SplitN(tag, ",", 2)[0] + if name != "" { + set[name] = struct{}{} + } + } + return set +} + +func assertFrozenKeys(t *testing.T, name string, got map[string]struct{}, want ...string) { + t.Helper() + wantSet := make(map[string]struct{}, len(want)) + for _, k := range want { + wantSet[k] = struct{}{} + } + for k := range got { + if _, ok := wantSet[k]; !ok { + t.Errorf("%s: 出现未冻结字段 %q —— 改契约需同步另一端(客户端 model)+ 文档", name, k) + } + } + for _, k := range want { + if _, ok := got[k]; !ok { + t.Errorf("%s: 缺失冻结字段 %q", name, k) + } + } +} + +func TestContractUsagePoint(t *testing.T) { + assertFrozenKeys(t, "/v1/usage UsagePoint", jsonTagSet(UsagePoint{}), + "date", "bytes_up", "bytes_down", "minutes_used", + ) +} + +func TestContractDeviceUsagePoint(t *testing.T) { + assertFrozenKeys(t, "/v1/usage/devices DeviceUsagePoint", jsonTagSet(DeviceUsagePoint{}), + "uuid", "name", "platform", "bytes_up", "bytes_down", "minutes_used", + ) +} + +func TestContractDeviceUsageResponse(t *testing.T) { + assertFrozenKeys(t, "/v1/usage/devices envelope", jsonTagSet(deviceUsageResponse{}), + "devices", + ) +}