mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 16:08:47 -07:00
feat: Enhance room save flow with new metadata handling and add tests for 2023 client integration
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
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<DorkNetDbContext>();
|
||||||
|
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<DorkNetDbContext>();
|
||||||
|
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<DorkNetDbContext>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1093,8 +1093,26 @@ public class RoomsController(
|
|||||||
public long RequestPlayerId { get; set; }
|
public long RequestPlayerId { get; set; }
|
||||||
public int SaveRequestPlayerId { 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 =>
|
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
|
public class CreatorActionContextDto
|
||||||
@@ -3411,9 +3429,14 @@ public class RoomsController(
|
|||||||
/// commit the blob returned by <c>storage/upload</c> as the current
|
/// commit the blob returned by <c>storage/upload</c> as the current
|
||||||
/// scene save. The 2020 watch posts this as form-urlencoded
|
/// scene save. The 2020 watch posts this as form-urlencoded
|
||||||
/// (<c>filename</c>, <c>inventionUsage</c>, <c>savedByAccountId</c>),
|
/// (<c>filename</c>, <c>inventionUsage</c>, <c>savedByAccountId</c>),
|
||||||
/// while some tooling uses JSON. Both variants delegate to the
|
/// while some tooling uses JSON. The 2023 client posts JSON with
|
||||||
/// existing saveData path so room, scene, dorm-state, presence, and
|
/// nested blob refs — <c>{UnityAssetId, RoomData:{Filename,Hash,
|
||||||
/// subscription fanout stay consistent.</summary>
|
/// OwnershipProof}, SubRoomData:{Filename,…}}</c> — where
|
||||||
|
/// <c>SubRoomData.Filename</c> is the scene save (FileType=1 upload)
|
||||||
|
/// and <c>RoomData.Filename</c> 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.</summary>
|
||||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
|||||||
@@ -44,15 +44,23 @@ public class StorageController(
|
|||||||
/// FileType enum values from the client's <c>RecNet.Storage+FileType</c>
|
/// FileType enum values from the client's <c>RecNet.Storage+FileType</c>
|
||||||
/// (dump.cs:586846). Kept as a private enum so callers can pattern-
|
/// (dump.cs:586846). Kept as a private enum so callers can pattern-
|
||||||
/// match without leaking client-side numbering elsewhere.
|
/// match without leaking client-side numbering elsewhere.
|
||||||
|
///
|
||||||
|
/// <c>RoomMetadata = 6</c> is new in the 2023 client: the room-save
|
||||||
|
/// flow (<c>OGPDOMCNIFM.UploadRoomDataBlobAndSyncReload</c>, 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 <c>rooms/{id}/subrooms/{id}/data</c>
|
||||||
|
/// as <c>{RoomData:{Filename},SubRoomData:{Filename}}</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private enum FileType
|
private enum FileType
|
||||||
{
|
{
|
||||||
Unknown = 0,
|
Unknown = 0,
|
||||||
RoomSave = 1,
|
RoomSave = 1,
|
||||||
Holotar = 2,
|
Holotar = 2,
|
||||||
Image = 3,
|
Image = 3,
|
||||||
Video = 4,
|
Video = 4,
|
||||||
Invention = 5,
|
Invention = 5,
|
||||||
|
RoomMetadata = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/upload", Order = -2000)]
|
[HttpPost("/upload", Order = -2000)]
|
||||||
@@ -103,6 +111,7 @@ public class StorageController(
|
|||||||
FileType.Image => await UploadImageAsync(playerId, bytes),
|
FileType.Image => await UploadImageAsync(playerId, bytes),
|
||||||
FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"),
|
FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"),
|
||||||
FileType.Video => await UploadGenericAsync(playerId, bytes, "video"),
|
FileType.Video => await UploadGenericAsync(playerId, bytes, "video"),
|
||||||
|
FileType.RoomMetadata => await UploadGenericAsync(playerId, bytes, "roommeta"),
|
||||||
_ => Ok(new
|
_ => Ok(new
|
||||||
{
|
{
|
||||||
filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}",
|
filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}",
|
||||||
|
|||||||
Reference in New Issue
Block a user