Files
Felis/internal/api/handlers_internal_menu_test.go
T

220 lines
7.3 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The internal-face claim + menu pair (spec §9.3, §12) is what velocity drives for
// the felis-paper lobby, which holds no token of its own. The claim mirrors the
// external Principal-gated claim but keys identity off the verified online-mode
// UUID; the menu adds the ownership-derived `claimable` flag the §11 lifecycle
// views never carry. These assert the exact wire keys, not just status, because a
// key velocity's parser reads but the handler never emits fails silently — every
// tile would read claimable=false forever and a status-only test would still pass.
const menuUUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
func internalClaim(api *API, body string) *httptest.ResponseRecorder {
return do(api.InternalHandler(), "POST", "/api/v1/internal/servers/survival/claim", body, nil)
}
func internalMenu(api *API) *httptest.ResponseRecorder {
return do(api.InternalHandler(), "GET", "/api/v1/internal/servers/survival/menu", "", nil)
}
func TestInternalClaim(t *testing.T) {
body := `{"mc_uuid":"` + menuUUID + `"}`
// linkAndQuota wires an API whose menuUUID is linked to userID and (optionally)
// has quota headroom, with the survival server present and claimable.
setup := func() (*API, *fakeRepo) {
repo := newFakeRepo()
cl := newFakeCluster()
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped"}
return newTestAPI(repo, cl), repo
}
t.Run("happy path: linked + quota + ownerless → 200 claimed", func(t *testing.T) {
api, repo := setup()
repo.links[menuUUID] = "user1" // account_links: UUID → user1 (implies linked)
repo.quota["user1"] = true
repo.claimOK["survival"] = true // UPDATE ... WHERE owner_id IS NULL hits 1 row
w := internalClaim(api, body)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
// Exact wire contract velocity's ClaimResponse parser reads.
if got["name"] != "survival" {
t.Fatalf("name = %v, want survival", got["name"])
}
if got["claimed"] != true {
t.Fatalf("claimed = %v, want true", got["claimed"])
}
// The claim is by the resolved user, and it is audited as a velocity action.
repo.assertClaimAudit(t, "survival")
})
t.Run("unlinked UUID → 412 not_linked, no claim", func(t *testing.T) {
api, repo := setup()
repo.claimOK["survival"] = true // would succeed if we got that far
// menuUUID is absent from repo.links → UserByMCUUID returns ErrNotFound.
w := internalClaim(api, body)
if w.Code != http.StatusPreconditionFailed {
t.Fatalf("code = %d, want 412 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "not_linked" {
t.Fatalf("error code = %q, want not_linked", code)
}
if len(repo.audits) != 0 {
t.Fatal("an unlinked claim must not be audited as a claim")
}
})
t.Run("over quota → 403 quota_exceeded", func(t *testing.T) {
api, repo := setup()
repo.links[menuUUID] = "user1"
repo.quota["user1"] = false // at the ceiling
repo.claimOK["survival"] = true
w := internalClaim(api, body)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "quota_exceeded" {
t.Fatalf("error code = %q, want quota_exceeded", code)
}
})
t.Run("already claimed (lost race) → 409", func(t *testing.T) {
api, repo := setup()
repo.links[menuUUID] = "user1"
repo.quota["user1"] = true
repo.claimOK["survival"] = false // present but UPDATE hits 0 rows
w := internalClaim(api, body)
if w.Code != http.StatusConflict {
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "already_claimed" {
t.Fatalf("error code = %q, want already_claimed", code)
}
})
t.Run("missing server → 404, distinct from the unlinked 412", func(t *testing.T) {
api, repo := setup()
repo.links[menuUUID] = "user1"
repo.quota["user1"] = true
// survival absent from repo.claimOK → ClaimServer returns ErrNotFound.
w := internalClaim(api, body)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
t.Run("missing mc_uuid is rejected", func(t *testing.T) {
api, repo := setup()
repo.claimOK["survival"] = true
w := internalClaim(api, `{}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
})
}
func TestInternalMenuStatus(t *testing.T) {
setup := func() (*API, *fakeRepo, *fakeCluster) {
repo := newFakeRepo()
cl := newFakeCluster()
return newTestAPI(repo, cl), repo, cl
}
t.Run("ownerless server → claimable, with exact keys", func(t *testing.T) {
api, _, cl := setup()
cl.byName["survival"] = &ServerInfo{
Name: "survival", Phase: "Running", Ready: true,
PlayersOnline: 3, PlayersMax: 20,
}
// no servers-row at all → treated as ownerless → claimable.
w := internalMenu(api)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
got := decodeMenu(t, w)
// Assert every key the lobby's MenuStatus parser reads, by exact name —
// the §11-trap guard: a renamed/absent key would read as a zero value.
assertEq(t, "name", got["name"], "survival")
assertEq(t, "phase", got["phase"], "Running")
assertEq(t, "ready", got["ready"], true)
assertEq(t, "playersOnline", got["playersOnline"], float64(3))
assertEq(t, "playersMax", got["playersMax"], float64(20))
assertEq(t, "claimable", got["claimable"], true)
})
t.Run("owned server → not claimable", func(t *testing.T) {
api, repo, cl := setup()
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped"}
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
got := decodeMenu(t, internalMenu(api))
assertEq(t, "claimable", got["claimable"], false)
})
t.Run("seeded but ownerless (empty owner_id) → claimable", func(t *testing.T) {
api, repo, cl := setup()
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped"}
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: ""}
got := decodeMenu(t, internalMenu(api))
assertEq(t, "claimable", got["claimable"], true)
})
t.Run("unknown server → 404", func(t *testing.T) {
api, _, _ := setup()
if w := internalMenu(api); w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
}
// ---- local helpers ----
func decodeMenu(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
return got
}
func assertEq(t *testing.T, key string, got, want any) {
t.Helper()
if got != want {
t.Fatalf("%s = %v (%T), want %v (%T)", key, got, got, want, want)
}
}
func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
t.Helper()
for _, e := range f.audits {
if e.Action == "claim" && e.ServerName == name && e.Actor == "velocity" && e.Source == "internal:velocity" {
return
}
}
t.Fatalf("no velocity/internal claim audit for %q in %+v", name, f.audits)
}