diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/admin/api_room_rename_test.go | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/admin/api_room_rename_test.go')
| -rw-r--r-- | internal/admin/api_room_rename_test.go | 149 |
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") + } +} |
