Files
Radicade/ServerTests/RoomSaveTests.cs
T
2026-10-16 23:33:13 -07:00

651 lines
26 KiB
C#

using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using RadicadeServer;
using RadicadeServer.DB;
using RadicadeServer.API.Studio;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.RoomServer;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// Room saves are the one path where losing a request loses a creator's work, and the
/// failure is silent: the client reports a successful save to the player, and the room
/// reopens with nothing in it. These pin the two things that caused exactly that - the
/// save body not binding, and a save with no uploaded blob being recorded anyway - plus
/// the history fallback that keeps a room loadable when one blob goes missing.
/// </summary>
[Collection(DbFactoryCollection.Name)]
public class RoomSaveTests
{
private const long CreatorId = 7;
private const long RoomId = 1;
private static readonly JsonSerializerOptions ServerJson = new()
{
PropertyNamingPolicy = null,
WriteIndented = true,
};
private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson);
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> options;
public TestDbContextFactory()
{
// A unique database name isolates each test; the store is NOT given its own
// InMemoryDatabaseRoot, because a distinct root makes Entity Framework build a
// distinct internal service provider every time and it throws once the process
// has more than twenty of them.
options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
}
public RecDBContext CreateDbContext() => new(options);
}
private static void SetUser(Controller controller, long accountId)
{
var identity = new ClaimsIdentity("TestAuth");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString()));
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
private static void SetJsonBody(Controller controller, string json)
{
var request = controller.ControllerContext.HttpContext.Request;
request.ContentType = "application/json";
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
}
private static void SetFormBody(Controller controller, string body)
{
var request = controller.ControllerContext.HttpContext.Request;
request.ContentType = "application/x-www-form-urlencoded";
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
}
/// <summary>Puts a blob in CDN storage, the way a completed upload would.</summary>
private static string WriteBlob(string name)
{
Directory.CreateDirectory(DBConfig.CDNStorage);
var path = Path.Join(DBConfig.CDNStorage, name);
File.WriteAllBytes(path, new byte[] { 1, 2, 3 });
return path;
}
private static void DeleteBlob(string name)
{
var path = Path.Join(DBConfig.CDNStorage, name);
if (File.Exists(path)) File.Delete(path);
}
/// <summary>
/// Reads a value back from the store through a NEW context. A context that has already
/// materialized an entity keeps serving its own copy, so reusing the seeding context
/// would report pre-repair values even after the repair was persisted.
/// </summary>
private static async Task<SubRoom> ReadPersistedSubRoomAsync(TestDbContextFactory factory, long subRoomId)
{
await using var db = factory.CreateDbContext();
return await db.SubRooms.AsNoTracking().FirstAsync(x => x.SubRoomId == subRoomId);
}
private static async Task<long> SeedRoomAsync(RecDBContext db, long creatorId = CreatorId, string dataBlob = "")
{
db.Rooms.Add(new RoomData
{
RoomId = RoomId,
Name = "Build Room",
CreatorAccountId = creatorId,
Accessibility = Accessibility.Public,
MaxPlayers = 8,
CreatedAt = DateTime.UtcNow,
});
var subRoom = new SubRoom
{
RoomId = RoomId,
Name = "Build Room",
UnitySceneId = "BuildRoomScene",
MaxPlayers = 8,
Accessibility = Accessibility.Public,
DataBlob = dataBlob,
};
db.SubRooms.Add(subRoom);
await db.SaveChangesAsync();
return subRoom.SubRoomId;
}
private static (TestDbContextFactory Factory, RecDBContext Db) CreateContext()
{
var factory = new TestDbContextFactory();
RoomUtils.DbFactory = factory;
MatchmakingUtils.DbFactory = factory;
return (factory, factory.CreateDbContext());
}
[Fact]
public async Task SaveAcceptsTheJsonEnvelopeThe2023ClientPosts()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db);
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """
{
"RoomData": { "Filename": "meta-1.bin" },
"SubRoomData": { "Filename": "scene-1.bin", "Hash": null },
"UnityAssetId": null,
"PersistenceVersion": 4,
"InventionUsage": "",
"AutoPublish": true
}
""");
WriteBlob("scene-1.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
// Studio's Result.EEKDNHEOFFD requires Success == (Error == null), so unlike the
// shared RecNet envelope this save endpoint must return a null error on success.
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("error").ValueKind);
// The loader downloads the room's `CurrentSave.DataBlob`; a save that records
// anything else opens empty.
var saved = doc.RootElement.GetProperty("value").GetProperty("room").GetProperty("SubRooms")[0];
Assert.Equal("scene-1.bin", saved.GetProperty("CurrentSave").GetProperty("DataBlob").GetString());
// The save the response hands back is the client's OWN projection: camelCase, and
// a different field set from that CurrentSave. Serving the room's save here leaves
// every field the client reads back null, and its save handling NREs on them.
var save = doc.RootElement.GetProperty("value").GetProperty("subRoomDataSave");
Assert.Equal("scene-1.bin", save.GetProperty("dataBlob").GetString());
Assert.Equal(subRoomId, save.GetProperty("subRoomId").GetInt64());
Assert.Equal(save.GetProperty("subRoomDataSaveId").GetInt64(), saved.GetProperty("CurrentSave").GetProperty("SubRoomDataSaveId").GetInt64());
Assert.Equal(JsonValueKind.Null, save.GetProperty("dataBlobHash").ValueKind);
Assert.Equal(JsonValueKind.Null, save.GetProperty("unityAsset").ValueKind);
Assert.False(save.TryGetProperty("DataBlob", out _));
// A subroom the client is shown has to name a creator: it NREs on a null one, so
// the account that saved it is reported from here on.
Assert.Equal(CreatorId, saved.GetProperty("CreatorAccountId").GetInt64());
// ...and both room spellings are present for the client's saver to read back.
Assert.Equal(RoomId, doc.RootElement.GetProperty("value").GetProperty("room").GetProperty("RoomId").GetInt64());
Assert.Equal(RoomId, doc.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64());
Assert.Equal("scene-1.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
await using var verify = factory.CreateDbContext();
Assert.True(await verify.SubRoomHistory.AnyAsync(x => x.SubRoomId == subRoomId));
}
finally { DeleteBlob("scene-1.bin"); }
}
[Fact]
public async Task SaveResponseEchoesTheBlobHashItWasGiven()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db);
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """
{
"SubRoomData": { "Filename": "hashed.bin", "Hash": "9f2c1a" },
"RoomData": { "Filename": "meta-2.bin" },
"AutoPublish": true
}
""");
WriteBlob("hashed.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
// The hash goes out on the save the response names, not on the room: it is the
// only digest either side has of the scene blob that save points at.
var save = doc.RootElement.GetProperty("value").GetProperty("subRoomDataSave");
Assert.Equal("hashed.bin", save.GetProperty("dataBlob").GetString());
Assert.Equal("9f2c1a", save.GetProperty("dataBlobHash").GetString());
}
finally { DeleteBlob("hashed.bin"); }
}
/// <summary>
/// A subroom that has never been saved has no creator and says so, rather than leaving the
/// key out: the client reads a missing key and a null the same way.
/// </summary>
[Fact]
public async Task AnUnsavedSubRoomReportsNoCreator()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db);
var room = await RoomUtils.GetRoom(RoomId);
Assert.NotNull(room);
var subRoom = room!.SubRooms.Single(x => x.SubRoomId == subRoomId);
Assert.Null(subRoom.CreatorAccountId);
}
[Fact]
public async Task SaveAcceptsTheLegacyFormFields()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db);
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetFormBody(api, "filename=legacy.bin&inventionUsage=&savedByAccountId=999");
WriteBlob("legacy.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("legacy.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
}
finally { DeleteBlob("legacy.bin"); }
}
[Fact]
public async Task SaveTakesTheSaverFromTheTokenNotTheBodyOrAPresenceRow()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db);
// No presence row at all: editing a room does not require one, and the old required
// `savedByAccountId` presence lookup turned an otherwise valid save into "Invalid auth".
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """{ "SubRoomData": { "Filename": "scene-2.bin" }, "SavedByAccountId": 999 }""");
WriteBlob("scene-2.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(CreatorId, (await ReadPersistedSubRoomAsync(factory, subRoomId)).SavedByAccountId);
}
finally { DeleteBlob("scene-2.bin"); }
}
[Fact]
public async Task StudioSourceManifestRouteReturnsHashAndHidesPrivateRoomSource()
{
var previousStorage = DBConfig.CDNStorage;
var isolatedStorage = Path.Combine(Path.GetTempPath(), "studio-source-route-" + Guid.NewGuid());
DBConfig.CDNStorage = isolatedStorage;
Directory.CreateDirectory(isolatedStorage);
var (_, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db);
const string manifestId = "0123456789abcdef0123456789abcdef01234567";
var manifestBytes = Encoding.UTF8.GetBytes("{\"Version\":2,\"Files\":[]}");
await File.WriteAllBytesAsync(Path.Combine(isolatedStorage, manifestId), manifestBytes);
var subRoom = await db.SubRooms.SingleAsync(x => x.SubRoomId == subRoomId);
subRoom.UnityAssetId = manifestId;
await db.SaveChangesAsync();
try
{
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
var result = Assert.IsType<OkObjectResult>(await api.GetStudioUnityAsset(RoomId, subRoomId));
var asset = Assert.IsType<StudioUnityAsset>(result.Value);
Assert.Equal(manifestId, asset.UnityAssetId);
Assert.Equal(manifestId, asset.Filename);
Assert.Equal(Convert.ToBase64String(SHA256.HashData(manifestBytes)), asset.Hash);
Assert.Empty(asset.BakedUnityAssets);
var room = await db.Rooms.SingleAsync(x => x.RoomId == RoomId);
room.Accessibility = Accessibility.Private;
await db.SaveChangesAsync();
SetUser(api, 42);
Assert.IsType<NotFoundResult>(await api.GetStudioUnityAsset(RoomId, subRoomId));
}
finally
{
DBConfig.CDNStorage = previousStorage;
if (Directory.Exists(isolatedStorage)) Directory.Delete(isolatedStorage, true);
}
}
[Fact]
public async Task SaveWithAMissingStudioManifestIsRejectedWithoutMutatingCurrentSaveOrHistory()
{
var previousStorage = DBConfig.CDNStorage;
var isolatedStorage = Path.Combine(Path.GetTempPath(), "studio-manifest-test-" + Guid.NewGuid());
DBConfig.CDNStorage = isolatedStorage;
Directory.CreateDirectory(isolatedStorage);
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db, dataBlob: "keep-manifest.bin");
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """{ "SubRoomData": { "Filename": "next-manifest.bin" }, "UnityAsset": { "Filename": "0000000000000000000000000000000000000000" } }""");
WriteBlob("keep-manifest.bin");
WriteBlob("next-manifest.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("Unity source manifest has not been uploaded.", doc.RootElement.GetProperty("error").GetString());
Assert.Equal("keep-manifest.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
Assert.Equal(string.Empty, (await ReadPersistedSubRoomAsync(factory, subRoomId)).UnityAssetId);
await using var verify = factory.CreateDbContext();
Assert.False(await verify.SubRoomHistory.AnyAsync(x => x.SubRoomId == subRoomId));
}
finally
{
DBConfig.CDNStorage = previousStorage;
if (Directory.Exists(isolatedStorage)) Directory.Delete(isolatedStorage, true);
}
}
[Fact]
public async Task SaveWithoutABlobNameIsRejectedAndLeavesTheCurrentSave()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db, dataBlob: "keep.bin");
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """{ "AutoPublish": true }""");
WriteBlob("keep.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("keep.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
await using var verify = factory.CreateDbContext();
Assert.False(await verify.SubRoomHistory.AnyAsync(x => x.SubRoomId == subRoomId));
}
finally { DeleteBlob("keep.bin"); }
}
[Fact]
public async Task SaveRequiresCoOwnerRights()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db, creatorId: 999, dataBlob: "keep.bin");
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """{ "SubRoomData": { "Filename": "scene-3.bin" } }""");
WriteBlob("scene-3.bin");
try
{
var result = await api.UploadRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("keep.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
}
finally { DeleteBlob("scene-3.bin"); }
}
[Fact]
public async Task RoomLoadFallsBackToTheNewestSaveStillOnDisk()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db, dataBlob: "gone.bin");
var newer = new RestorableSubRoom
{
RoomId = RoomId,
SubRoomId = subRoomId,
DataBlob = "also-gone.bin",
SavedByAccountId = CreatorId,
CreatedAt = DateTime.UtcNow,
};
var older = new RestorableSubRoom
{
RoomId = RoomId,
SubRoomId = subRoomId,
DataBlob = "old-1.bin",
SavedByAccountId = CreatorId,
CreatedAt = DateTime.UtcNow.AddHours(-1),
};
db.SubRoomHistory.AddRange(newer, older);
await db.SaveChangesAsync();
WriteBlob("old-1.bin");
try
{
var room = await RoomUtils.GetRoom(RoomId);
Assert.NotNull(room);
Assert.Equal("old-1.bin", room!.SubRooms.Single(x => x.SubRoomId == subRoomId).DataBlob);
// The repair is persisted: it is a recovery, not a per-read patch-up.
Assert.Equal("old-1.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
}
finally { DeleteBlob("old-1.bin"); }
}
/// <summary>Seeds a subroom with a live save plus two revisions behind it.</summary>
private static async Task<long> SeedSaveHistoryAsync(RecDBContext db)
{
var subRoomId = await SeedRoomAsync(db, dataBlob: "live.bin");
db.SubRoomHistory.AddRange(
new RestorableSubRoom
{
RoomId = RoomId,
SubRoomId = subRoomId,
DataBlob = "newer.bin",
SavedByAccountId = CreatorId,
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
},
new RestorableSubRoom
{
RoomId = RoomId,
SubRoomId = subRoomId,
DataBlob = "older.bin",
SavedByAccountId = CreatorId,
CreatedAt = DateTime.UtcNow.AddHours(-2),
});
await db.SaveChangesAsync();
return subRoomId;
}
private static List<string?> BlobsIn(JsonElement page) =>
page.GetProperty("Results").EnumerateArray()
.Select(x => x.GetProperty("DataBlob").GetString())
.ToList();
/// <summary>
/// The save list the client asks for on entering a room. A 404 here is the whole history
/// screen broken, and any revision missing from it is a save nobody can restore.
/// </summary>
[Fact]
public async Task SavesPageListsTheLiveRevisionAndItsHistoryNewestFirst()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedSaveHistoryAsync(db);
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
var ok = Assert.IsType<OkObjectResult>(await api.GetSubRoomSaves(RoomId, subRoomId));
using var doc = JsonDocument.Parse(Serialize(ok.Value));
// The live save is what the room is running, so it leads; the revisions follow,
// newest first.
Assert.Equal(new List<string?> { "live.bin", "newer.bin", "older.bin" }, BlobsIn(doc.RootElement));
// Both spellings of the total, because the client's paged DTO and the reference
// disagree on the name - and it counts the history, not the page.
Assert.Equal(3, doc.RootElement.GetProperty("TotalResults").GetInt32());
Assert.Equal(3, doc.RootElement.GetProperty("TotalCount").GetInt32());
// Ids are what the client keys the list by: the live save is 1 (the id the room
// payload's CurrentSave already carries) and every revision is distinct.
var ids = doc.RootElement.GetProperty("Results").EnumerateArray()
.Select(x => x.GetProperty("SubRoomDataSaveId").GetInt64()).ToList();
Assert.Equal(3, ids.Distinct().Count());
Assert.Equal(1, ids[0]);
Assert.All(ids, id => Assert.True(id >= 1));
// A page is a window onto the history, not the whole of it.
var paged = Assert.IsType<OkObjectResult>(await api.GetSubRoomSaves(RoomId, subRoomId, 1, 1));
using var pagedDoc = JsonDocument.Parse(Serialize(paged.Value));
Assert.Equal(new List<string?> { "newer.bin" }, BlobsIn(pagedDoc.RootElement));
Assert.Equal(3, pagedDoc.RootElement.GetProperty("TotalResults").GetInt32());
// The lighter twin carries the same rows.
var light = Assert.IsType<OkObjectResult>(await api.GetSubRoomSavesWithoutUnityAssets(RoomId, subRoomId));
using var lightDoc = JsonDocument.Parse(Serialize(light.Value));
Assert.Equal(new List<string?> { "live.bin", "newer.bin", "older.bin" }, BlobsIn(lightDoc.RootElement));
Assert.False(lightDoc.RootElement.GetProperty("Results")[0].TryGetProperty("UnitySubAssets", out _));
}
[Fact]
public async Task SavesPageIsEmptyRatherThanMissingForAnUnknownSubroom()
{
var (factory, db) = CreateContext();
await using var _db = db;
await SeedRoomAsync(db);
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
// An unknown id answers an empty page, so a client holding a stale one still renders.
var ok = Assert.IsType<OkObjectResult>(await api.GetSubRoomSaves(RoomId, 9999));
using var doc = JsonDocument.Parse(Serialize(ok.Value));
Assert.Empty(doc.RootElement.GetProperty("Results").EnumerateArray());
Assert.Equal(0, doc.RootElement.GetProperty("TotalResults").GetInt32());
}
[Fact]
public async Task SavesAreReadableByAStrangerOnlyWhileTheyAreInTheRoom()
{
const long visitorId = 42;
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedSaveHistoryAsync(db);
var api = new RoomServerAPI(db);
SetUser(api, visitorId);
// Not the creator and holding no role: the list exposes unpublished work, so from
// outside the room it is a 403.
var refused = Assert.IsType<StatusCodeResult>(await api.GetSubRoomSaves(RoomId, subRoomId));
Assert.Equal(StatusCodes.Status403Forbidden, refused.StatusCode);
// Standing in the room is its own grant: the client resolves which version to load
// from this list, so a visitor who cannot read it cannot load the room.
var instance = new RoomInstance { roomId = RoomId, subRoomId = subRoomId };
db.RoomInstances.Add(instance);
await db.SaveChangesAsync();
db.Presences.Add(new RealPresence { playerId = visitorId, isOnline = true, roomInstance = instance.roomInstanceId });
await db.SaveChangesAsync();
var ok = Assert.IsType<OkObjectResult>(await api.GetSubRoomSaves(RoomId, subRoomId));
using var doc = JsonDocument.Parse(Serialize(ok.Value));
Assert.Equal(3, doc.RootElement.GetProperty("TotalResults").GetInt32());
// A visitor in a DIFFERENT room is not in this one.
instance.roomId = RoomId + 1;
await db.SaveChangesAsync();
var elsewhere = Assert.IsType<StatusCodeResult>(await api.GetSubRoomSaves(RoomId, subRoomId));
Assert.Equal(StatusCodes.Status403Forbidden, elsewhere.StatusCode);
}
[Fact]
public async Task RestoreKeepsTheRevisionItReplaced()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db, dataBlob: "current.bin");
var api = new RoomServerAPI(db);
SetUser(api, CreatorId);
SetJsonBody(api, """{ "Filename": "older.bin" }""");
WriteBlob("older.bin");
try
{
var result = await api.RestoreRoomData(RoomId, subRoomId);
using var doc = JsonDocument.Parse(Serialize(result));
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
// Restoring is the one path that can drop a save outright: the revision it
// replaced has to be in the history afterwards, or it is gone.
await using var verify = factory.CreateDbContext();
var history = await verify.SubRoomHistory.Where(x => x.SubRoomId == subRoomId).ToListAsync();
Assert.Contains(history, x => x.DataBlob == "current.bin");
Assert.Equal("older.bin", (await ReadPersistedSubRoomAsync(factory, subRoomId)).DataBlob);
}
finally { DeleteBlob("older.bin"); }
}
[Fact]
public async Task RoomLoadClearsTheSaveOnlyWhenNothingIsRecoverable()
{
var (factory, db) = CreateContext();
await using var _db = db;
var subRoomId = await SeedRoomAsync(db, dataBlob: "gone.bin");
var room = await RoomUtils.GetRoom(RoomId);
Assert.NotNull(room);
Assert.Equal(string.Empty, room!.SubRooms.Single(x => x.SubRoomId == subRoomId).DataBlob);
}
}