diff options
| author | historia <[not public]> | 2026-09-18 21:44:49 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:44:49 -0400 |
| commit | e7848ea79a9a57fdf391d30c48ce649f3d2f6180 (patch) | |
| tree | a2972facd5bbe88006f40ff96a1eeadd570ac15b /internal/admin/api_entity_test.go | |
| parent | a8a67482bbff8297a96adff65d2fd7029ed998cc (diff) | |
| download | thehouseoficarus-e7848ea79a9a57fdf391d30c48ce649f3d2f6180.tar.gz | |
refactor: dedup admin entity handlers and server construction
Diffstat (limited to 'internal/admin/api_entity_test.go')
| -rw-r--r-- | internal/admin/api_entity_test.go | 147 |
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) + } +} |
