mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Update API and documentation to ensure sub-room saves return a paged object; fix deserialization issues for the 2023 client
This commit is contained in:
@@ -260,6 +260,65 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
return root.GetProperty("RoomId").GetInt64();
|
return root.GetProperty("RoomId").GetInt64();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
|
||||||
|
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
|
||||||
|
/// (PagedResultsDTO<SubRoomDataSaveDTO>), NOT a bare array. The 2023
|
||||||
|
/// client's GetSubRoomDataSaves strict reader throws
|
||||||
|
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
|
||||||
|
/// calling flow — room clone fails with the message-less "Failed to copy
|
||||||
|
/// room" and the private-instance button silently does nothing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
|
||||||
|
{
|
||||||
|
using var setupClient = ApiClient();
|
||||||
|
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||||
|
|
||||||
|
var roomId = 9_300_888L;
|
||||||
|
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 = $"SavesShape_{Guid.NewGuid():N}"[..20],
|
||||||
|
CreatorPlayerId = owner.PlayerId,
|
||||||
|
ImageName = RoomService.DefaultRoomImageName,
|
||||||
|
Accessibility = 1,
|
||||||
|
CurrentDataBlobName = $"room_{roomId}_v1.dat",
|
||||||
|
});
|
||||||
|
db.RoomScenes.Add(new RoomSceneEntity
|
||||||
|
{
|
||||||
|
RoomId = roomId,
|
||||||
|
OrderIndex = 0,
|
||||||
|
Name = "Home",
|
||||||
|
DataBlobName = $"room_{roomId}_v1.dat",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
||||||
|
foreach (var path in new[]
|
||||||
|
{
|
||||||
|
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
|
||||||
|
$"/rooms/{roomId}/subrooms/0/saves",
|
||||||
|
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
using var resp = await roomsClient.GetAsync(path);
|
||||||
|
var text = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
|
||||||
|
using var json = JsonDocument.Parse(text);
|
||||||
|
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
|
||||||
|
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|||||||
@@ -601,11 +601,30 @@ public class RoomsController(
|
|||||||
/// list of saves for a subroom
|
/// list of saves for a subroom
|
||||||
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
|
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
|
||||||
/// Returns the current save plus any historical blobs recorded for the
|
/// Returns the current save plus any historical blobs recorded for the
|
||||||
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.</summary>
|
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.
|
||||||
|
///
|
||||||
|
/// <para><b>Wire shape is a PAGED OBJECT, not a bare list.</b> The 2023
|
||||||
|
/// client's <c>GetSubRoomDataSaves</c> (RecNet.Runtime
|
||||||
|
/// NLDBPDCNNCF.LDLMCGBIJFH, state machine IOAKBKKDFEE, sends
|
||||||
|
/// <c>skip</c>/<c>take</c>) deserializes
|
||||||
|
/// <c>PagedResultsDTO<SubRoomDataSaveDTO></c> —
|
||||||
|
/// <c>{"Results":[…],"TotalResults":N}</c> per the Studio decompile
|
||||||
|
/// (RRStudio-decomp <c>PagedResultsDTO.cs</c>, plain .NET names). A
|
||||||
|
/// bare array throws <c>expected:'{', actual:'['</c> in the strict
|
||||||
|
/// reader, which kills the whole calling flow: room clone dies with
|
||||||
|
/// the message-less "Failed to copy room" and the private-instance
|
||||||
|
/// button silently does nothing.</para></summary>
|
||||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
||||||
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
||||||
|
// Studio editor asks for the same page without the heavy Unity-asset
|
||||||
|
// payload (CLAJDMOFFOS.PGGIWOJWHOM). Our rows never carry inline asset
|
||||||
|
// bytes, so the same handler serves both.
|
||||||
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")]
|
||||||
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> SubRoomSaves(long roomId, long subRoomId)
|
public async Task<IActionResult> SubRoomSaves(
|
||||||
|
long roomId, long subRoomId,
|
||||||
|
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||||
{
|
{
|
||||||
var room = await rooms.GetByIdAsync(roomId);
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
@@ -650,12 +669,25 @@ public class RoomsController(
|
|||||||
SavedByAccountId = h.UploadedByPlayerId,
|
SavedByAccountId = h.UploadedByPlayerId,
|
||||||
SavedOnPlatform = 7,
|
SavedOnPlatform = 7,
|
||||||
SavedOnDeviceClass = 2,
|
SavedOnDeviceClass = 2,
|
||||||
SavedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
|
// Match BuildStudioSaveWire's key set — the strict readers
|
||||||
|
// key on CreatedAt/CreatedByAccountId (SavedAt was our own
|
||||||
|
// invention and no reader consumes it).
|
||||||
|
CreatedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
|
||||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||||
|
CreatedByAccountId = h.UploadedByPlayerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(saves);
|
var total = saves.Count;
|
||||||
|
var page = saves
|
||||||
|
.Skip(Math.Max(0, skip))
|
||||||
|
.Take(take > 0 ? take : 50)
|
||||||
|
.ToList();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Results = page,
|
||||||
|
TotalResults = total,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Build one subroom entry in the wire shape the watch's
|
/// <summary>Build one subroom entry in the wire shape the watch's
|
||||||
|
|||||||
@@ -107,6 +107,18 @@ Clones are created **private** (`Accessibility=0`) regardless of the
|
|||||||
source's visibility; the owner publishes via
|
source's visibility; the owner publishes via
|
||||||
`rooms/{id}/accessibility` when ready.
|
`rooms/{id}/accessibility` when ready.
|
||||||
|
|
||||||
|
### Sub-room saves list is a PAGED OBJECT
|
||||||
|
|
||||||
|
`GET rooms/{id}/subrooms/{sub}/saves` (and `…/saves/no_unity_assets`)
|
||||||
|
must return `{"Results":[…],"TotalResults":N}` —
|
||||||
|
`PagedResultsDTO<SubRoomDataSaveDTO>` per the Studio decompile
|
||||||
|
(`RRStudio-decomp/...RecNet/PagedResultsDTO.cs`); the 2023 client's
|
||||||
|
`GetSubRoomDataSaves` sends `skip`/`take` and its strict reader throws
|
||||||
|
`expected:'{', actual:'['` on a bare array. Because callers run inside
|
||||||
|
larger flows, the bare-array bug surfaced as "Failed to copy room:
|
||||||
|
Exception of type '…' was thrown" (clone) and a dead private-instance
|
||||||
|
button — not as a saves error.
|
||||||
|
|
||||||
### Private rooms: one instance per sub-room
|
### Private rooms: one instance per sub-room
|
||||||
|
|
||||||
Private rooms (`Accessibility=0`) allow exactly ONE instance per
|
Private rooms (`Accessibility=0`) allow exactly ONE instance per
|
||||||
|
|||||||
Reference in New Issue
Block a user