1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
|
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)
}
}
|