From 5460cc36013b255f78ec9d5eea2931dfb0ccdeb2 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:54:12 +0300 Subject: [PATCH] feat: Add regression tests for room cloning functionality; ensure cloned rooms return full details --- DorkNet.Server.Tests/RoomSave2023Tests.cs | 70 +++++++++++++++++++ .../API/Rooms/V2/RoomsModerationController.cs | 18 +++-- .../Controllers/Cdn/CdnController.cs | 22 ++++-- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs index 2b486aa..d847bf5 100644 --- a/DorkNet.Server.Tests/RoomSave2023Tests.cs +++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs @@ -130,6 +130,76 @@ public sealed class RoomSave2023Tests : IClassFixture $"expected a RoomDataBlobs row for {filename}"); } + /// + /// Regression: cloning a room — and then cloning the CLONE — must both + /// succeed and return the full room-details shape (FGCPNAACHIK). Previously + /// the handler relied on DB auto-increment for the room id AND the copied + /// scene ids, which collide with the seeded id range once the identity + /// sequence lags → the clone POST 500s and the client boots the player to + /// their dorm ("Failed to copy room: Failed to clone room"). + /// + [Fact] + public async Task Cloning_a_room_and_then_the_clone_both_succeed() + { + using var setupClient = ApiClient(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var roomId = 9_300_777L; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) + { + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"CloneSrc_{Guid.NewGuid():N}"[..20], + Description = "clone source", + CreatorPlayerId = owner.PlayerId, + ImageName = RoomService.DefaultRoomImageName, + Accessibility = 1, + IsAGRoom = false, + IsDormRoom = false, + CloningAllowed = true, + CurrentDataBlobName = $"room_{roomId}_v1.dat", + }); + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = roomId, + OrderIndex = 0, + Name = "Main", + DataBlobName = $"room_{roomId}_v1.dat", + }); + await db.SaveChangesAsync(); + } + } + + using var roomsClient = ApiClient(owner, subdomain: "rooms"); + + var firstCloneId = await CloneAndAssertAsync(roomsClient, roomId, "FirstClone"); + Assert.NotEqual(roomId, firstCloneId); + + // The regression: cloning the clone. + var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone"); + Assert.NotEqual(firstCloneId, secondCloneId); + } + + private static async Task CloneAndAssertAsync(HttpClient client, long sourceId, string name) + { + using var resp = await client.PostAsync( + $"/rooms/{sourceId}/clone", + new StringContent(JsonSerializer.Serialize(new { name }), Encoding.UTF8, "application/json")); + var text = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}"); + + using var json = JsonDocument.Parse(text); + var root = json.RootElement; + // Full room-details shape (FGCPNAACHIK), not a status wrapper. + foreach (var key in new[] { "RoomId", "SubRooms", "Roles" }) + Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}"); + return root.GetProperty("RoomId").GetInt64(); + } + private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api") { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index b996b03..1fb8a5c 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -1046,8 +1046,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase .Where(s => s.RoomId == source.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); + // Scene ids need to be explicit for the same reason room ids do — + // RoomSceneEntity.Id is an auto/identity column whose Postgres sequence + // lags behind the manually-seeded rows, so relying on DB generation + // collides and the whole clone SaveChanges 500s. + var nextSceneId = await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L; var clonedScenes = sourceScenes.Select(s => new RoomSceneEntity { + Id = ++nextSceneId, RoomId = clone.Id, OrderIndex = s.OrderIndex, Name = s.Name, @@ -1069,10 +1075,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) // deserializes the response as the FULL room-details object - // (FGCPNAACHIK — the same type get-by-id / rename return), NOT the - // slim list-room shape. Returning ToWireRoom here made the strict - // reader fail → "Failed to copy room: Failed to clone room". - return Ok(RoomsController.BuildRoomDetails(clone, clonedScenes)); + // (FGCPNAACHIK — the same type get-by-id / rename return on the + // roomserver host), NOT the slim list-room shape. This is the + // roomserver-family path (rooms/{id}/clone), so use + // BuildRoomServerDetails — the exact shape GET rooms/{id} returns for + // the 2023 client. Returning ToWireRoom (or the wrong details variant) + // made the strict reader fail → "Failed to copy room: Failed to clone + // room". + return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes)); } public sealed class CloneBareRequest { public string? Name { get; set; } } diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 8577d9d..5709dd7 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -564,11 +564,23 @@ public class CdnController( r.IsAGRoom && r.CurrentDataBlobName == fileName)) return true; - if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase) && - fileName.EndsWith("_v1.dat", StringComparison.OrdinalIgnoreCase) && - long.TryParse(fileName[5..^7], out var roomId) && - await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom)) - return true; + // Synthetic/default room-data blob names for rooms with no saved blob: + // the CURRENT form is room_{id}_dorknet_v8.dat + // (RoomService.SyntheticDefaultRoomDataBlobName) and the legacy form is + // room_{id}_v1.dat. Baked RRO rooms (empty CurrentDataBlobName) load + // their permissions from this synthetic blob, so it MUST overlay too — + // otherwise MakerPen only appears in RRO rooms that happen to have a + // real saved blob (which is why it worked for Rec Center / Stunt Runner + // but not the rest). Parse the id from any room_{id}_* form. + if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase)) + { + var rest = fileName["room_".Length..]; + var underscore = rest.IndexOf('_'); + if (underscore > 0 && + long.TryParse(rest[..underscore], out var roomId) && + await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom)) + return true; + } return await ( from scene in db.RoomScenes.AsNoTracking()