fix(cli): Sync picker hides system servers; keep the two 409 refusals apart

Two defects from the live Sync drill:

- The picker listed the system servers (login/lobby), which the backup API can
  never accept (reserved names, no servers row): the pick died in name
  validation with a raw "server name is reserved" error. backupPickable now
  filters them out; the halt picker keeps them on purpose (break-glass retains
  full power over system servers).
- backupErrorFromResponse mapped every 409 to the stopped gate, so the new
  world-volume refusal would have displayed the wrong reason. The 409 arm now
  keys on the body's error code; a code-less body still reads as the stopped
  gate.

Live (auditfix38): the picker shows only user servers; a world-less pick shows
the API's own "no world volume yet — start it once" text; the not_stopped text
is unchanged.
This commit is contained in:
Lemon-miaow committed 2026-09-23 07:49:07 +08:00
1 parent 508a1c02da
commit ac3a557566
3 files changed
+66 -13

No files matched your search

+32 -3
View File
@@ -114,16 +114,23 @@ func TestRequestBackup(t *testing.T) {
cases := []struct {
name string
code int
body string // optional JSON error body
expect string
}{
{"409 not_stopped", http.StatusConflict, "must be stopped"},
{"503 backup_unavailable", http.StatusServiceUnavailable, "not configured"},
{"404 not found", http.StatusNotFound, "no such server"},
{"409 not_stopped", http.StatusConflict, "", "must be stopped"},
{"409 no_world_volume surfaces the API's own text", http.StatusConflict,
`{"error":{"code":"no_world_volume","message":"this server has no world volume yet — start it once to create it, then retry"}}`,
"no world volume yet"},
{"503 backup_unavailable", http.StatusServiceUnavailable, "", "not configured"},
{"404 not found", http.StatusNotFound, "", "no such server"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.code)
if tc.body != "" {
_, _ = io.WriteString(w, tc.body)
}
}))
defer srv.Close()
_, err := requestBackup(context.Background(), hc, srv.URL, "tok", "survival", "alice")
@@ -142,3 +149,25 @@ func TestRequestBackup(t *testing.T) {
}
})
}
// The Sync picker must not offer system servers: the backup API validates names
// and resolves a servers-table row, so a lobby/login pick can only die in
// validation — a dead choice in an emergency console.
func TestBackupPickable(t *testing.T) {
got := backupPickable([]haltableServer{
{name: "lobby", phase: "Running", system: true},
{name: "login", phase: "Running", system: true},
{name: "test-one", phase: "Stopped"},
})
if len(got) != 1 || got[0].name != "test-one" || got[0].system {
t.Fatalf("backupPickable = %+v, want only the user server", got)
}
// Survivors keep their input order (the picker's cursor math depends on it).
got = backupPickable([]haltableServer{
{name: "alpha"}, {name: "login", system: true}, {name: "beta"},
})
if len(got) != 2 || got[0].name != "alpha" || got[1].name != "beta" {
t.Fatalf("backupPickable order = %+v, want [alpha beta]", got)
}
}