test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认
This commit is contained in:
44 files changed
+10578
-216
No files matched your search
@@ -746,7 +746,9 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) {
|
||||
return f.mine[u], nil
|
||||
// Fresh rows per call, as PGRepo scans them: the handler joins live state
|
||||
// into the slice it gets.
|
||||
return append([]MyServerView(nil), f.mine[u]...), nil
|
||||
}
|
||||
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
|
||||
if f.ownersErr != nil {
|
||||
@@ -1535,6 +1537,7 @@ type fakeCluster struct {
|
||||
byName map[string]*ServerInfo
|
||||
bySub map[string]*ServerInfo
|
||||
list []ServerInfo
|
||||
listErr error
|
||||
desired map[string]v1alpha1.DesiredState
|
||||
created map[string]CreateServerInput // name -> the validated input it was created from
|
||||
patched map[string]ServerSpecPatch // name -> the validated spec patch it received
|
||||
@@ -1567,8 +1570,13 @@ func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo,
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil }
|
||||
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
|
||||
func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) {
|
||||
if c.listErr != nil {
|
||||
return nil, c.listErr
|
||||
}
|
||||
return c.list, nil
|
||||
}
|
||||
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
|
||||
|
||||
// WorldVolumeExists models the world PVC: present unless the test named the
|
||||
// server in noWorld (never started / already reaped).
|
||||
@@ -1860,6 +1868,71 @@ func TestMeIdentity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMyServersJoinsLiveState proves /me/servers carries the live CRD fields the
|
||||
// panel renders: every row gets the display name, live phase and counts, and
|
||||
// only the caller's own rows get owner detail (desired state, autostart policy,
|
||||
// an unreadable player count), as the status route withholds them from others.
|
||||
// A cluster read failure keeps the Postgres rows with the cached phase.
|
||||
func TestMyServersJoinsLiveState(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.mine = map[string][]MyServerView{"u1": {
|
||||
{Name: "mine", Subdomain: "mine", Owned: true, Phase: "Stopped"},
|
||||
{Name: "open", Subdomain: "open", Claimable: true, Phase: "Stopped"},
|
||||
}}
|
||||
cl := newFakeCluster()
|
||||
cl.list = []ServerInfo{
|
||||
{Name: "mine", DisplayName: "My World", Phase: "Running", DesiredState: "Running",
|
||||
AutostartPolicy: "ownerOnly", PlayersOnline: 2, PlayersMax: 20, PlayerCountUnknown: true},
|
||||
{Name: "open", DisplayName: "Open World", Phase: "Running", DesiredState: "Running",
|
||||
AutostartPolicy: "public", PlayersMax: 10, PlayerCountUnknown: true},
|
||||
}
|
||||
api := newTestAPI(repo, cl)
|
||||
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
|
||||
|
||||
read := func() map[string]map[string]any {
|
||||
t.Helper()
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Servers []map[string]any `json:"servers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for _, s := range body.Servers {
|
||||
out[s["name"].(string)] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
got := read()
|
||||
mine, open := got["mine"], got["open"]
|
||||
for k, want := range map[string]any{"displayName": "My World", "phase": "Running",
|
||||
"desiredState": "Running", "autostartPolicy": "ownerOnly", "playerCountUnknown": true,
|
||||
"playersOnline": float64(2), "playersMax": float64(20)} {
|
||||
if mine[k] != want {
|
||||
t.Errorf("own row %s = %v, want %v", k, mine[k], want)
|
||||
}
|
||||
}
|
||||
if open["displayName"] != "Open World" || open["phase"] != "Running" || open["playersMax"] != float64(10) {
|
||||
t.Errorf("claimable row public fields = %v", open)
|
||||
}
|
||||
for _, k := range []string{"desiredState", "autostartPolicy", "playerCountUnknown"} {
|
||||
if _, ok := open[k]; ok {
|
||||
t.Errorf("claimable row carries owner detail %s = %v", k, open[k])
|
||||
}
|
||||
}
|
||||
|
||||
cl.listErr = errors.New("apiserver down")
|
||||
got = read()
|
||||
if got["mine"]["phase"] != "Stopped" || got["mine"]["playersOnline"] != float64(0) {
|
||||
t.Errorf("cluster down: own row = %v, want cached phase and 0 players", got["mine"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- fleet (SysAdmin cockpit read) ----
|
||||
|
||||
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
|
||||
|
||||
@@ -239,19 +239,34 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Player counts are presentational and best-effort, mirroring handleFleet's
|
||||
// The live fields are presentational and best-effort, mirroring handleFleet's
|
||||
// owner join: the list exists for ownership/claim state, so a cluster hiccup
|
||||
// must degrade to 0/0 counts, never 500 the whole list. The CRD status is the
|
||||
// only source of live counts (spec §1) — Postgres never stores them.
|
||||
// must degrade to 0/0 counts and the cached phase, never 500 the whole list.
|
||||
// The CRD status is the only source of live state (spec §1) — Postgres never
|
||||
// stores counts. Owner detail (desired state, autostart policy, whether the
|
||||
// count is readable) joins only onto rows the caller owns; the panel needs
|
||||
// playerCountUnknown there to ask before a stop that may drop players.
|
||||
if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
|
||||
byName := make(map[string]ServerInfo, len(infos))
|
||||
for _, s := range infos {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
for i := range servers {
|
||||
if info, ok := byName[servers[i].Name]; ok {
|
||||
servers[i].PlayersOnline = info.PlayersOnline
|
||||
servers[i].PlayersMax = info.PlayersMax
|
||||
info, ok := byName[servers[i].Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v := &servers[i]
|
||||
v.PlayersOnline = info.PlayersOnline
|
||||
v.PlayersMax = info.PlayersMax
|
||||
v.DisplayName = info.DisplayName
|
||||
if info.Phase != "" {
|
||||
v.Phase = info.Phase
|
||||
}
|
||||
if v.Owned {
|
||||
v.DesiredState = info.DesiredState
|
||||
v.AutostartPolicy = info.AutostartPolicy
|
||||
v.PlayerCountUnknown = info.PlayerCountUnknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+17
-11
@@ -18,18 +18,24 @@ type ServerRecord struct {
|
||||
}
|
||||
|
||||
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
|
||||
// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in
|
||||
// Postgres — handleMyServers joins them best-effort from the CRD status
|
||||
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never
|
||||
// a 500.
|
||||
// may auto-start, or may claim. Everything after Claimable is NOT stored in
|
||||
// Postgres — handleMyServers joins it best-effort from the CRD status
|
||||
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0 and the
|
||||
// cached phase, never a 500. DesiredState, AutostartPolicy and
|
||||
// PlayerCountUnknown are owner detail and stay empty on rows the caller does
|
||||
// not own, the same split publicServerInfo makes on the status route.
|
||||
type MyServerView struct {
|
||||
Name string `json:"name"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Owned bool `json:"owned"`
|
||||
Claimable bool `json:"claimable"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
PlayersOnline int32 `json:"playersOnline"`
|
||||
PlayersMax int32 `json:"playersMax"`
|
||||
Name string `json:"name"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Owned bool `json:"owned"`
|
||||
Claimable bool `json:"claimable"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
PlayersOnline int32 `json:"playersOnline"`
|
||||
PlayersMax int32 `json:"playersMax"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
DesiredState string `json:"desiredState,omitempty"`
|
||||
AutostartPolicy string `json:"autostartPolicy,omitempty"`
|
||||
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
|
||||
|
||||
Reference in new issue
Block a user