package api import ( "encoding/json" "net/http" "slices" "strconv" "strings" "testing" ) type documentedSchema struct { Ref string `json:"$ref"` Const json.RawMessage `json:"const"` Required []string `json:"required"` Properties map[string]*documentedSchema `json:"properties"` } type documentedOperation struct { Responses map[string]struct { Content map[string]struct{ Schema documentedSchema } } } func TestOpenAPIResponseContracts(t *testing.T) { var spec struct { Paths map[string]map[string]json.RawMessage Components struct{ Schemas map[string]documentedSchema } } if err := json.Unmarshal(openAPISpec, &spec); err != nil { t.Fatal(err) } operation := func(path, method string) documentedOperation { t.Helper() var op documentedOperation if err := json.Unmarshal(spec.Paths[path][method], &op); err != nil { t.Fatal(err) } return op } // Downloads, raw files, resource proxies, and OpenAPI itself do not use the business JSON envelope. raw := map[string]bool{ "get /openapi.json": true, "get /files/{path}": true, "get /raw/{path}": true, "get /plugins/{id}/assets/{revision}/{path}": true, "get /fonts/{id}/file": true, "get /resources/{ticket}/{kind}/{path}": true, "post /export": true, "post /maintenance/export/{kind}": true, "post /maintenance/notes/export": true, "post /maintenance/logs/export": true, } for path, methods := range spec.Paths { for method := range methods { if method == "parameters" { continue } for status, response := range operation(path, method).Responses { if !strings.HasPrefix(status, "2") || status == "204" || raw[method+" "+path] { continue } t.Run(method+" "+path+" "+status, func(t *testing.T) { body, ok := response.Content["application/json"] if !ok { t.Fatal("普通成功响应缺少 JSON 定义") } for _, key := range []string{"code", "data", "message"} { if !slices.Contains(body.Schema.Required, key) || body.Schema.Properties[key] == nil { t.Fatalf("响应未声明必填字段 %s", key) } } if string(body.Schema.Properties["code"].Const) != "0" { t.Fatal("成功业务码必须为 0") } }) } } } for _, tc := range []struct { path string statuses []string }{ {"/fonts", []string{"200", "201"}}, {"/maintenance/uploads", []string{"201"}}, {"/maintenance/restore", []string{"202"}}, } { var actual []string for status := range operation(tc.path, "post").Responses { if strings.HasPrefix(status, "2") { actual = append(actual, status) } } slices.Sort(actual) if !slices.Equal(actual, tc.statuses) { t.Errorf("POST %s 成功状态错误: %v", tc.path, actual) } } f := newFixture(t) check := func(method, path string, response *http.Response) { t.Helper() defer response.Body.Close() documented, ok := operation(path, method).Responses[strconv.Itoa(response.StatusCode)] if !ok || response.StatusCode >= 300 { t.Fatalf("%s %s 实际成功状态未正确声明: %d", method, path, response.StatusCode) } var body struct { Code int Data map[string]json.RawMessage } if err := json.NewDecoder(response.Body).Decode(&body); err != nil { t.Fatal(err) } if body.Code != 0 || body.Data == nil { t.Fatalf("实际响应不是成功 JSON: %+v", body) } schema := *documented.Content["application/json"].Schema.Properties["data"] if schema.Ref != "" { schema = spec.Components.Schemas[strings.TrimPrefix(schema.Ref, "#/components/schemas/")] } for key := range body.Data { if schema.Properties[key] == nil { t.Errorf("%s %s 未声明实际字段 data.%s", method, path, key) } } for _, key := range schema.Required { if _, ok := body.Data[key]; !ok { t.Errorf("%s %s 未返回文档必填字段 data.%s", method, path, key) } } } check("get", "/metadata", f.do(f.admin, "GET", "/api/v1/metadata", "", nil)) check("get", "/fonts", f.do(f.admin, "GET", "/api/v1/fonts", "", nil)) for range 2 { check("post", "/fonts", uploadTestFont(t, f, f.admin, "contract.woff2", testFontData(t))) } check("get", "/maintenance/status", f.do(f.admin, "GET", "/api/v1/maintenance/status", "", nil)) check("post", "/maintenance/uploads", f.do(f.admin, "POST", "/api/v1/maintenance/uploads", `{"kind":"settings","size":10}`, nil)) id := stagedPackage(t, f, "full", exportedPackage(t, f, "full")) // Verify only the submission contract; the synthetic instance must not trigger a real process restart or restore. f.server.Restart = func() {} check("post", "/maintenance/restore", f.do(f.admin, "POST", "/api/v1/maintenance/restore", `{"id":"`+id+`","confirmed":true}`, nil)) }