test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认
This commit is contained in:
44 files changed
+10578
-216
No files matched your search
@@ -0,0 +1,190 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/build"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
// TestOpenAPISchemasMatchWireStructs keeps docs/openapi.yaml honest about the
|
||||
// response bodies. Each named schema is compared with the Go struct the handler
|
||||
// actually encodes: the property set must equal the struct's JSON field set, and
|
||||
// `required` must list exactly the fields that are always on the wire (no
|
||||
// omitempty). The panel's types are checked against the same schemas at compile
|
||||
// time (panel/src/lib/types.parity.ts), so a field added here without the docs
|
||||
// fails in Go, and one added to the docs without the panel fails in tsc.
|
||||
func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../docs/openapi.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Components struct {
|
||||
Schemas map[string]schemaDoc `json:"schemas"`
|
||||
} `json:"components"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("parse openapi.yaml: %v", err)
|
||||
}
|
||||
|
||||
pairs := map[string]any{
|
||||
"ServerInfo": ServerInfo{},
|
||||
"FleetServer": fleetServerView{},
|
||||
"MyServerView": MyServerView{},
|
||||
"BackupView": BackupView{},
|
||||
"Build": build.Build{},
|
||||
"Image": build.Image{},
|
||||
"Submission": submissionView{},
|
||||
"UserView": UserView{},
|
||||
"UserDetail": UserDetail{},
|
||||
"QuotaView": QuotaView{},
|
||||
"SessionView": SessionView{},
|
||||
"PasskeyCredential": passkeyCredentialView{},
|
||||
"UpdateWindow": updateWindow{},
|
||||
"DBBackupStatus": dbBackupView{},
|
||||
}
|
||||
for name, v := range pairs {
|
||||
s, ok := doc.Components.Schemas[name]
|
||||
if !ok {
|
||||
t.Errorf("openapi.yaml has no components.schemas.%s", name)
|
||||
continue
|
||||
}
|
||||
compareSchema(t, name, flatten(s, doc.Components.Schemas), reflect.TypeOf(v))
|
||||
}
|
||||
}
|
||||
|
||||
type schemaDoc struct {
|
||||
Ref string `json:"$ref"`
|
||||
AllOf []schemaDoc `json:"allOf"`
|
||||
Type any `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]schemaDoc `json:"properties"`
|
||||
Items *schemaDoc `json:"items"`
|
||||
}
|
||||
|
||||
// flatten resolves a top-level $ref and merges allOf parts into one object
|
||||
// schema, which is how a Go struct embedding another one is documented.
|
||||
func flatten(s schemaDoc, all map[string]schemaDoc) schemaDoc {
|
||||
if s.Ref != "" {
|
||||
s = all[strings.TrimPrefix(s.Ref, "#/components/schemas/")]
|
||||
}
|
||||
if len(s.AllOf) == 0 {
|
||||
return s
|
||||
}
|
||||
out := schemaDoc{Properties: map[string]schemaDoc{}}
|
||||
for _, part := range append(s.AllOf, schemaDoc{Required: s.Required, Properties: s.Properties}) {
|
||||
part = flatten(part, all)
|
||||
out.Required = append(out.Required, part.Required...)
|
||||
for k, v := range part.Properties {
|
||||
out.Properties[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type wireField struct {
|
||||
omitempty bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// wireFields lists the JSON fields encoding/json emits for t, following
|
||||
// embedded structs the way the encoder does.
|
||||
func wireFields(t reflect.Type) map[string]wireField {
|
||||
out := map[string]wireField{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
tag := f.Tag.Get("json")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, opts, _ := strings.Cut(tag, ",")
|
||||
if f.Anonymous && name == "" {
|
||||
for k, v := range wireFields(f.Type) {
|
||||
out[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func structOf(t reflect.Type) (reflect.Type, bool) {
|
||||
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t, t.Kind() == reflect.Struct && t.PkgPath() != "time"
|
||||
}
|
||||
|
||||
func compareSchema(t *testing.T, path string, s schemaDoc, typ reflect.Type) {
|
||||
t.Helper()
|
||||
fields := wireFields(typ)
|
||||
var missing, extra, notRequired, wronglyRequired []string
|
||||
for name, f := range fields {
|
||||
if _, ok := s.Properties[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
if !f.omitempty && !contains(s.Required, name) {
|
||||
notRequired = append(notRequired, name)
|
||||
}
|
||||
}
|
||||
for name := range s.Properties {
|
||||
if _, ok := fields[name]; !ok {
|
||||
extra = append(extra, name)
|
||||
}
|
||||
}
|
||||
for _, name := range s.Required {
|
||||
if f, ok := fields[name]; ok && f.omitempty {
|
||||
wronglyRequired = append(wronglyRequired, name)
|
||||
}
|
||||
}
|
||||
report := func(what string, names []string) {
|
||||
if len(names) > 0 {
|
||||
sort.Strings(names)
|
||||
t.Errorf("%s: %s: %s", path, what, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
report("sent by Go but missing from openapi.yaml", missing)
|
||||
report("documented but never sent by Go", extra)
|
||||
report("always sent but not in required", notRequired)
|
||||
report("required but omitted when empty", wronglyRequired)
|
||||
|
||||
// Nested objects (an object property, or an array of objects) are held to
|
||||
// the same rule when the schema spells their properties out.
|
||||
for name, f := range fields {
|
||||
p, ok := s.Properties[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
inner, isStruct := structOf(f.typ)
|
||||
if !isStruct {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(p.Properties) > 0:
|
||||
compareSchema(t, path+"."+name, p, inner)
|
||||
case p.Items != nil && len(p.Items.Properties) > 0:
|
||||
compareSchema(t, path+"."+name+"[]", *p.Items, inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user