test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认

This commit is contained in:
Lemon-miaow committed 2026-09-25 10:47:19 +08:00
1 parent ea425cffa4
commit 2f99874d5e
44 files changed
+10578 -216

No files matched your search

+76 -3
View File
@@ -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
+21 -6
View File
@@ -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
}
}
}
+190
View File
@@ -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
View File
@@ -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: