aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_room_rename_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/api_room_rename_test.go')
-rw-r--r--internal/admin/api_room_rename_test.go149
1 files changed, 149 insertions, 0 deletions
diff --git a/internal/admin/api_room_rename_test.go b/internal/admin/api_room_rename_test.go
new file mode 100644
index 0000000..8d273f4
--- /dev/null
+++ b/internal/admin/api_room_rename_test.go
@@ -0,0 +1,149 @@
+package admin
+
+// Room-rename mutation-plan regressions: the rename, the reference rewrites
+// (including self-loops), the runtime migration callback, undo, redo, and
+// injected partial-write failures must all agree.
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func renameRoom(t *testing.T, s *AdminServer, id, newID int) *httptest.ResponseRecorder {
+ t.Helper()
+ body, _ := json.Marshal(map[string]int{"id": id, "new_id": newID})
+ req := httptest.NewRequest(http.MethodPost, "/api/room/rename", strings.NewReader(string(body)))
+ rec := httptest.NewRecorder()
+ s.handleRoomRename(rec, req)
+ return rec
+}
+
+func roomBody(t *testing.T, s *AdminServer, id int) string {
+ t.Helper()
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ return ""
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
+
+// TestRoomRenameFullPlanCoversReferencesAndSelfLoop: renaming a room that a
+// neighbor references and that references itself rewrites the neighbor, the
+// renamed room's own id field AND self-loop, moves the file, and records the
+// whole plan for undo.
+func TestRoomRenameFullPlan(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 5: "id: 5\nname: Lab\nexits:\n north:\n room: 6\n east:\n room: 5\n",
+ 6: "id: 6\nname: Hall\nexits:\n south:\n room: 5\n",
+ })
+
+ var migrated []map[int]int
+ // The wired callback mirrors production (the game's MigrateRoomIDs):
+ // record the map and rebuild the world room index after migration.
+ migrate := func(idMap map[int]int) {
+ recorded := map[int]int{}
+ for k, v := range idMap {
+ recorded[k] = v
+ }
+ migrated = append(migrated, recorded)
+ s.world.RebuildRoomIndex(s.dataDir)
+ }
+ s.rewriteRoomIDs = migrate
+ s.undoStack.SetMigrateRoomIDs(migrate)
+
+ rec := renameRoom(t, s, 5, 7)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("rename failed: %d %s", rec.Code, rec.Body.String())
+ }
+
+ // Renamed room file: moved, id field rewritten, self-loop corrected.
+ selfBody := roomBody(t, s, 7)
+ if !strings.Contains(selfBody, "room: 7") {
+ t.Fatalf("renamed room self-loop not rewritten: %q", selfBody)
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "rooms", "5.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("old room file still exists: %v", err)
+ }
+ // The referencing room's exit moved.
+ hallBody := roomBody(t, s, 6)
+ if !strings.Contains(hallBody, "room: 7") {
+ t.Fatalf("reference rewrite missing: %q", hallBody)
+ }
+ // The runtime migration callback ran with the forward map.
+ if len(migrated) != 1 {
+ t.Fatalf("runtime migration callback not invoked with one plan: %v", migrated)
+ }
+
+ // Undo: everything returns to before-images, inverse migration runs.
+ if s.undoStack.Undo() == nil {
+ t.Fatal("undo failed")
+ }
+ if roomBody(t, s, 5) == "" {
+ t.Fatal("undo did not restore the renamed room at its old path")
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "rooms", "7.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("undo left the new room file behind: %v", err)
+ }
+ if hall := roomBody(t, s, 6); strings.Contains(hall, "room: 7") {
+ t.Fatalf("undo did not restore the referencing room: %q", hall)
+ }
+ // The last migration callback must be the inverse map.
+ if last := migrated[len(migrated)-1]; !(len(last) == 1 && last[7] == 5) {
+ t.Fatalf("undo migration callback = %v, want inverse {7:5}", last)
+ }
+
+ // Redo: forward again, whole plan re-applied.
+ if s.undoStack.Redo() == nil {
+ t.Fatal("redo failed")
+ }
+ if !strings.Contains(roomBody(t, s, 6), "room: 7") {
+ t.Fatal("redo did not re-apply the reference rewrite")
+ }
+ if roomBody(t, s, 5) != "" {
+ t.Fatal("redo resurrected the old room file")
+ }
+ if last := migrated[len(migrated)-1]; !(len(last) == 1 && last[5] == 7) {
+ t.Fatalf("redo migration callback = %v, want forward {5:7}", last)
+ }
+}
+
+// TestRoomRenameAbortsOnWriteFailure: when the write side fails mid-plan,
+// the journal transaction rolls back — the old room file and its references
+// stay untouched, and no half-renamed state survives.
+func TestRoomRenameAbortsOnWriteFailure(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 5: "id: 5\nname: Lab\nexits:\n north:\n room: 6\n",
+ 6: "id: 6\nname: Hall\nexits:\n south:\n room: 5\n",
+ })
+
+ // A read-only rooms directory makes every room-file write fail.
+ roomsDir := filepath.Join(s.dataDir, "rooms")
+ if err := os.Chmod(roomsDir, 0500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(roomsDir, 0700) })
+
+ rec := renameRoom(t, s, 5, 7)
+ if rec.Code == http.StatusOK {
+ t.Fatalf("rename succeeded despite the write failure: %s", rec.Body.String())
+ }
+ os.Chmod(roomsDir, 0700)
+
+ // Nothing was renamed: room 5 still exists at its old path with its
+ // original content, and room 7 does not exist.
+ if body := roomBody(t, s, 5); !strings.Contains(body, "id: 5") {
+ t.Fatalf("room 5 damaged by failed rename: %q", body)
+ }
+ if roomExists(s, 7) {
+ t.Fatal("room 7 exists after the failed rename")
+ }
+}