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") } }