From 1417651c77bf5a79b31e72c85a5e573dc426b104 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:19:19 +0300 Subject: [PATCH] feat: Enhance room save flow with new metadata handling and add tests for 2023 client integration --- DorkNet.Server.Tests/RoomSave2023Tests.cs | 128 ++++++++++++++++++ .../API/Rooms/V2/RoomsController.cs | 31 ++++- .../Controllers/Storage/StorageController.cs | 21 ++- 3 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 DorkNet.Server.Tests/RoomSave2023Tests.cs diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs new file mode 100644 index 0000000..1fee3a9 --- /dev/null +++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// 2023 room-save flow (captured from the 2023.03.21 client against dev, +/// 2026-07-07). The client uploads two blobs to storage /upload — +/// FileType=6 room metadata, then FileType=1 scene save — and commits +/// them with a JSON POST to rooms/{id}/subrooms/{id}/data: +/// { "UnityAssetId": null, +/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null }, +/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } } +/// SubRoomData.Filename is the scene blob and must become the room's +/// CurrentDataBlobName. +/// +public sealed class RoomSave2023Tests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob() + { + using var setupClient = ApiClient(); + var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + var roomId = 9_200_001L; + var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat"; + 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 = $"Save2023_{Guid.NewGuid():N}"[..24], + Description = "2023 save flow test room", + CreatorPlayerId = owner.PlayerId, + ImageName = RoomService.DefaultRoomImageName, + Accessibility = 1, + IsAGRoom = true, + IsDormRoom = false, + }); + await db.SaveChangesAsync(); + } + else + { + await db.Rooms.Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId)); + } + } + + using var roomsClient = ApiClient(owner, subdomain: "rooms"); + var body = new + { + UnityAssetId = (long?)null, + RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null }, + SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null }, + }; + using var response = await roomsClient.PostAsync( + $"/rooms/{roomId}/subrooms/0/data", + new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")); + + var responseText = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}"); + + using var json = JsonDocument.Parse(responseText); + Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId); + Assert.Equal(blobName, room.CurrentDataBlobName); + } + } + + [Fact] + public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed() + { + using var setupClient = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); + + using var storageClient = ApiClient(player, subdomain: "storage"); + using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST"); + var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + form.Add(file, "File", "file.bin"); + form.Add(new StringContent("6"), "FileType"); + + using var response = await storageClient.PostAsync("/upload", form); + var responseText = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}"); + + using var json = JsonDocument.Parse(responseText); + var filename = json.RootElement.GetProperty("filename").GetString(); + Assert.NotNull(filename); + Assert.StartsWith("roommeta_", filename); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.True( + await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename), + $"expected a RoomDataBlobs row for {filename}"); + } + + private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api") + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index fcd3738..a392282 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1093,8 +1093,26 @@ public class RoomsController( public long RequestPlayerId { get; set; } public int SaveRequestPlayerId { get; set; } + // 2023 client shape. rooms/{id}/subrooms/{id}/data arrives as JSON: + // { "UnityAssetId": null, + // "RoomData": { "Filename": "roommeta_….bin", "Hash": …, "OwnershipProof": … }, + // "SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": …, "OwnershipProof": … } } + // SubRoomData.Filename is the scene save blob (the FileType=1 + // /upload result) — that's what must become DataBlobName. + // RoomData.Filename is the FileType=6 room-metadata blob. + public long? UnityAssetId { get; set; } + public BlobRefDto? RoomData { get; set; } + public BlobRefDto? SubRoomData { get; set; } + public string EffectiveRoomDataFilename => - RoomDataFilename ?? Filename ?? string.Empty; + RoomDataFilename ?? Filename ?? SubRoomData?.Filename ?? string.Empty; + } + + public class BlobRefDto + { + public string? Filename { get; set; } + public string? Hash { get; set; } + public string? OwnershipProof { get; set; } } public class CreatorActionContextDto @@ -3411,9 +3429,14 @@ public class RoomsController( /// commit the blob returned by storage/upload as the current /// scene save. The 2020 watch posts this as form-urlencoded /// (filename, inventionUsage, savedByAccountId), - /// while some tooling uses JSON. Both variants delegate to the - /// existing saveData path so room, scene, dorm-state, presence, and - /// subscription fanout stay consistent. + /// while some tooling uses JSON. The 2023 client posts JSON with + /// nested blob refs — {UnityAssetId, RoomData:{Filename,Hash, + /// OwnershipProof}, SubRoomData:{Filename,…}} — where + /// SubRoomData.Filename is the scene save (FileType=1 upload) + /// and RoomData.Filename the FileType=6 room-metadata blob. + /// All variants delegate to the existing saveData path so room, + /// scene, dorm-state, presence, and subscription fanout stay + /// consistent. [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [Authorize] diff --git a/DorkNet.Server/Controllers/Storage/StorageController.cs b/DorkNet.Server/Controllers/Storage/StorageController.cs index 9de2e3d..37cf06c 100644 --- a/DorkNet.Server/Controllers/Storage/StorageController.cs +++ b/DorkNet.Server/Controllers/Storage/StorageController.cs @@ -44,15 +44,23 @@ public class StorageController( /// FileType enum values from the client's RecNet.Storage+FileType /// (dump.cs:586846). Kept as a private enum so callers can pattern- /// match without leaking client-side numbering elsewhere. + /// + /// RoomMetadata = 6 is new in the 2023 client: the room-save + /// flow (OGPDOMCNIFM.UploadRoomDataBlobAndSyncReload, logs + /// "Uploading room metadata {0}/{1}") uploads a small metadata blob + /// with FileType=6 alongside the FileType=1 scene save, then posts + /// both returned filenames to rooms/{id}/subrooms/{id}/data + /// as {RoomData:{Filename},SubRoomData:{Filename}}. /// private enum FileType { - Unknown = 0, - RoomSave = 1, - Holotar = 2, - Image = 3, - Video = 4, - Invention = 5, + Unknown = 0, + RoomSave = 1, + Holotar = 2, + Image = 3, + Video = 4, + Invention = 5, + RoomMetadata = 6, } [HttpPost("/upload", Order = -2000)] @@ -103,6 +111,7 @@ public class StorageController( FileType.Image => await UploadImageAsync(playerId, bytes), FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"), FileType.Video => await UploadGenericAsync(playerId, bytes, "video"), + FileType.RoomMetadata => await UploadGenericAsync(playerId, bytes, "roommeta"), _ => Ok(new { filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}",