package admin // Bulk-edit mutation-plan regressions: a same-directory "move" must never // delete the room, a failure anywhere must leave every room untouched (no // partial changes without an undo record), and a successful plan applies as // one unit with a single undo record. import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func postBulkEdit(t *testing.T, s *AdminServer, body string) (map[string]any, int) { t.Helper() req := httptest.NewRequest(http.MethodPost, "/api/rooms/bulk-edit", strings.NewReader(body)) rec := httptest.NewRecorder() s.handleBulkEdit(rec, req) var resp map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &resp) return resp, rec.Code } // TestBulkEditSameDirectoryMovePreservesRoom: moving a room to the // directory it already lives in is an in-place edit, not a delete. The old // implementation removed the file and reported success. func TestBulkEditSameDirectoryMovePreservesRoom(t *testing.T) { s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"}) oldPath, _ := s.world.GetRoomPath(1) dir := filepath.Join(s.dataDir, "rooms", "area") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } dest := filepath.Join(dir, "1.yaml") if err := os.Rename(oldPath, dest); err != nil { t.Fatal(err) } s.world.RebuildRoomIndex(s.dataDir) resp, code := postBulkEdit(t, s, `{"ids":[1],"patch":{"dir":"area"}}`) if code != http.StatusOK { t.Fatalf("bulk edit failed: %d %v", code, resp) } if _, err := os.Stat(dest); err != nil { t.Fatalf("same-directory move deleted the room: %v", err) } if _, err := os.Stat(oldPath); !os.IsNotExist(err) { t.Fatal("old path should not reappear for an in-place edit") } } // TestBulkEditFailureLeavesNoPartialChanges: a parse failure on any selected // room aborts the whole plan before any file changes, so no room is mutated // without an undo record. func TestBulkEditFailureLeavesNoPartialChanges(t *testing.T) { s := newTestAdminServer(t, map[int]string{ 1: "id: 1\nname: Room\n", 2: "invalid: [\n", }) path, _ := s.world.GetRoomPath(1) before, err := os.ReadFile(path) if err != nil { t.Fatal(err) } resp, code := postBulkEdit(t, s, `{"ids":[1,2],"patch":{"color":"red"}}`) if code == http.StatusOK && resp["error"] == nil { t.Fatalf("bulk edit unexpectedly succeeded: %v", resp) } if resp["error"] == nil { t.Fatalf("expected an error response, got: %v", resp) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(after) != string(before) { t.Fatalf("failed bulk edit mutated room 1: before=%q after=%q", before, after) } if s.undoStack.Info().StackSize != 0 { t.Fatal("failed bulk edit pushed an undo record") } } // TestBulkEditSuccessSingleUndoRecord: a valid plan applies every patch and // pushes exactly one undo record whose undo restores every file. func TestBulkEditSuccessSingleUndoRecord(t *testing.T) { s := newTestAdminServer(t, map[int]string{ 1: "id: 1\nname: Room\n", 2: "id: 2\nname: Other\n", }) resp, code := postBulkEdit(t, s, `{"ids":[1,2],"patch":{"color":"red"}}`) if code != http.StatusOK { t.Fatalf("bulk edit failed: %d %v", code, resp) } for _, id := range []int{1, 2} { path, _ := s.world.GetRoomPath(id) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "color: red") { t.Fatalf("room %d not patched: %q", id, data) } } if info := s.undoStack.Info(); info.StackSize != 1 { t.Fatalf("undo stack size = %d, want exactly 1 plan", info.StackSize) } if s.undoStack.Undo() == nil { t.Fatal("undo failed") } path, _ := s.world.GetRoomPath(1) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(string(data), "color: red") { t.Fatalf("undo did not restore room 1: %q", data) } }