aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_entity_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/api_entity_test.go')
-rw-r--r--internal/admin/api_entity_test.go147
1 files changed, 147 insertions, 0 deletions
diff --git a/internal/admin/api_entity_test.go b/internal/admin/api_entity_test.go
new file mode 100644
index 0000000..b3d83a7
--- /dev/null
+++ b/internal/admin/api_entity_test.go
@@ -0,0 +1,147 @@
+package admin
+
+// Regression coverage for the shared entity CRUD handlers (api_entity.go):
+// both wire styles must keep their exact contract after the per-entity
+// handlers were folded into one implementation.
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func newEntityHarness(t *testing.T) *AdminServer {
+ t.Helper()
+ dir := t.TempDir()
+ for _, sub := range []string{"items", "techs", "modules"} {
+ if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+}
+
+func callHandler(h http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h(rec, req)
+ return rec
+}
+
+// Status style (items): validation errors are HTTP statuses, writes echo the
+// entity map including _raw.
+func TestEntityCRUDStatusStyle(t *testing.T) {
+ s := newEntityHarness(t)
+ list := s.handleEntityList(itemSpec)
+ byID := s.handleEntityByID(itemSpec)
+
+ if rec := callHandler(list, http.MethodPost, "/api/items", `{"id":"coin","name":"Coin","value":1}`); rec.Code != http.StatusOK {
+ t.Fatalf("create status = %d body %s", rec.Code, rec.Body.String())
+ }
+ // The id is written into the file for the status style.
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "items", "coin.yaml"))
+ if err != nil || !strings.Contains(string(data), "id: coin") {
+ t.Fatalf("created file = %q err=%v", data, err)
+ }
+
+ rec := callHandler(byID, http.MethodGet, "/api/items/coin", "")
+ var got map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("get not json: %v (%s)", err, rec.Body.String())
+ }
+ if _, ok := got["_raw"]; !ok {
+ t.Fatalf("status-style get did not include _raw: %s", rec.Body.String())
+ }
+
+ rec = callHandler(byID, http.MethodPut, "/api/items/coin", `{"name":"Coin","value":9}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("update status = %d body %s", rec.Code, rec.Body.String())
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["_raw"] == nil {
+ t.Fatalf("status-style update did not echo _raw: %v (%s)", err, rec.Body.String())
+ }
+
+ // Traversal ids are rejected with a 400 (not a 200 body error).
+ rec = callHandler(list, http.MethodPost, "/api/items", `{"id":"../escape"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("traversal id status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
+ }
+
+ if rec := callHandler(byID, http.MethodDelete, "/api/items/coin", ""); rec.Code != http.StatusOK {
+ t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "items", "coin.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("delete left the file behind: %v", err)
+ }
+}
+
+// Body style (techs): errors are a 200 JSON body, writes reply {"ok":true},
+// and PUT upserts an absent file.
+func TestEntityCRUDUpsertStyle(t *testing.T) {
+ s := newEntityHarness(t)
+ list := s.handleEntityList(techSpec)
+ byID := s.handleEntityByID(techSpec)
+
+ rec := callHandler(byID, http.MethodPut, "/api/techs/laser", `{"name":"Laser"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("upsert status = %d body %s", rec.Code, rec.Body.String())
+ }
+ var resp map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp["ok"] != true {
+ t.Fatalf("upsert response = %s (err %v)", rec.Body.String(), err)
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "techs", "laser.yaml")); err != nil {
+ t.Fatalf("upsert did not create the file: %v", err)
+ }
+
+ // The body style strips the id from the file (derived from the filename).
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "techs", "laser.yaml"))
+ if err != nil || strings.Contains(string(data), "id:") {
+ t.Fatalf("body-style file should not store id: %q err=%v", data, err)
+ }
+
+ // Traversal ids are a 200 body error, not a 400 (the newer editors read
+ // res.error out of a 200).
+ rec = callHandler(list, http.MethodPost, "/api/techs", `{"id":"../escape"}`)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "invalid id") {
+ t.Fatalf("body-style traversal id = %d %s; want 200 with 'invalid id'", rec.Code, rec.Body.String())
+ }
+
+ // GET echoes the id from the filename.
+ rec = callHandler(byID, http.MethodGet, "/api/techs/laser", "")
+ var got map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["id"] != "laser" {
+ t.Fatalf("body-style get id = %v (err %v, body %s)", got["id"], err, rec.Body.String())
+ }
+}
+
+// A by-id write must update a file that lives in a subdirectory, not create a
+// shadowing copy at the root. The old module handler resolved updates only at
+// the root while GET/DELETE searched subdirs.
+func TestEntityUpsertUpdatesFileInSubdir(t *testing.T) {
+ s := newEntityHarness(t)
+ sub := filepath.Join(s.dataDir, "modules", "sub")
+ if err := os.MkdirAll(sub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ subPath := filepath.Join(sub, "m1.yaml")
+ if err := os.WriteFile(subPath, []byte("name: Original\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ rec := callHandler(s.handleEntityByID(moduleSpec), http.MethodPut, "/api/modules/m1", `{"name":"Updated"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("subdir update status = %d body %s", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "modules", "m1.yaml")); !os.IsNotExist(err) {
+ t.Fatal("subdir update created a shadowing root copy")
+ }
+ data, err := os.ReadFile(subPath)
+ if err != nil || !strings.Contains(string(data), "Updated") {
+ t.Fatalf("subdir file not updated: %q err=%v", data, err)
+ }
+}