using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Keepsakes; using RadicadeServer.DB; using RadicadeServer.RecNet.Keepsakes; using Xunit; namespace RadicadeServer.Tests; /// /// The keepsake store: a placed keepsake is the room save's GUID, seeing a room without one is /// still NO CONTENT, and collecting is per player and idempotent. /// public class KeepsakesStoreTests : IDisposable { private const long Room = 25; private readonly KeepsakeRoomConfig? _previousRooms; private readonly bool? _previousFeature; /// /// These tests exercise the feature, so they configure it rather than inheriting whatever the /// shipped config says: keepsakes are OFF unless a keepsake room is configured /// (), and the shipped file lists no room. Pinning "every room is a /// keepsake room" here is also what makes a test's own override - the "no keepsake rooms" cases /// below - a deliberate change rather than a fallback. /// public KeepsakesStoreTests() { _previousRooms = KeepsakeRoomConfig.Override; _previousFeature = KeepsakeFeature.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""); KeepsakeFeature.Override = null; } public void Dispose() { KeepsakeRoomConfig.Override = _previousRooms; KeepsakeFeature.Override = _previousFeature; } private static RecDBContext NewDb() => new RecDBContext(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); private static KeepsakesAPI CreateApi(RecDBContext db, long accountId = 2, string? body = null) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; if (body != null) { context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); context.Request.ContentType = "application/json"; } return new KeepsakesAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; } private static Guid PlacedGuid(IActionResult result) { var ok = Assert.IsType(result); var json = JsonSerializer.SerializeToElement(ok.Value); return json.GetProperty("KeepsakeInstanceId").GetGuid(); } /// /// The room's keepsake carries the id the ROOM SAVE already has. Minting a different one here /// is how a room ends up with a keepsake the server knows under another name, so the id in the /// placement body is the id that has to come back. /// [Fact] public async Task PlacementKeepsTheRoomSavesInstanceId() { var savedId = Guid.Parse("a75f7547-79eb-47c6-8986-6767abcb4f92"); await using var db = NewDb(); var api = CreateApi(db, body: $$""" {"RoomId":{{Room}},"SubRoomId":null,"KeepsakeCategoryConfigId":0, "KeepsakeInstanceId":"{{savedId}}"} """); var placed = await api.Place(Room); Assert.Equal(savedId, PlacedGuid(placed)); var stored = await db.KeepsakeInstances.SingleAsync(); Assert.Equal(Room, stored.RoomId); Assert.Equal(0, stored.KeepsakeCategoryId); Assert.Equal(2, stored.PlacedByAccountId); } [Fact] public async Task UnknownCategoryIsRejectedRatherThanStored() { await using var db = NewDb(); var api = CreateApi(db, body: """{"KeepsakeCategoryConfigId":99}"""); Assert.IsType(await api.Place(Room)); Assert.Empty(db.KeepsakeInstances); } /// /// One placed keepsake, seen by the player who placed it: it is an instance to collect and /// there is no collection record yet. This is the body the room-load phase reads - an id here /// that has no row behind it is what faults the load, so the two lists move together. /// [Fact] public async Task RoomServesTheInstancesAndTheCallersRecords() { await using var db = NewDb(); var id = Guid.NewGuid(); await CreateApi(db, body: $$"""{"KeepsakeCategoryConfigId":5,"KeepsakeInstanceId":"{{id}}"}""").Place(Room); var result = Assert.IsType(await CreateApi(db).Room(Room)); var json = JsonSerializer.SerializeToElement(result.Value); var instance = Assert.Single(json.GetProperty("KeepsakeInstances").EnumerateArray()); Assert.Equal(id, instance.GetProperty("KeepsakeInstanceId").GetGuid()); Assert.Equal(5, instance.GetProperty("KeepsakeCategoryConfigId").GetInt32()); Assert.Equal(Room, instance.GetProperty("KeepsakeRoomId").GetInt64()); Assert.Empty(json.GetProperty("KeepsakeCollectionRecords").EnumerateArray()); // A permitted room names the keepsake event, which is what the client checks for before it // offers a keepsake at all - the list of events is never empty for one of these rooms. Assert.NotEmpty(json.GetProperty("KeepsakeProgressionEventIds").EnumerateArray()); } [Fact] public async Task CollectingMovesTheInstanceIntoTheCallersRecords() { await using var db = NewDb(); var id = Guid.NewGuid(); await CreateApi(db, body: $$"""{"KeepsakeCategoryConfigId":6,"KeepsakeInstanceId":"{{id}}"}""").Place(Room); var collected = Assert.IsType(await CreateApi(db, body: $$"""{"KeepsakeInstanceIds":["{{id}}"]}""").Collect(Room)); var collectedJson = JsonSerializer.SerializeToElement(collected.Value); Assert.Equal(Room, collectedJson.GetProperty("KeepsakeRoomId").GetInt64()); var result = Assert.IsType(await CreateApi(db).Room(Room)); var json = JsonSerializer.SerializeToElement(result.Value); var record = Assert.Single(json.GetProperty("KeepsakeCollectionRecords").EnumerateArray()); Assert.Equal(id, record.GetProperty("KeepsakeInstanceId").GetGuid()); Assert.Equal(6, record.GetProperty("KeepsakeCategoryConfigId").GetInt32()); // Collected keepsakes are no longer instances to hand out - a second body for the same // keepsake would put the player back in front of something they already have. Assert.Empty(json.GetProperty("KeepsakeInstances").EnumerateArray()); } [Fact] public async Task CollectingTwiceKeepsTheFirstRecord() { await using var db = NewDb(); var id = Guid.NewGuid(); await CreateApi(db, body: $$"""{"KeepsakeCategoryConfigId":0,"KeepsakeInstanceId":"{{id}}"}""").Place(Room); await CreateApi(db, body: $$"""{"KeepsakeInstanceIds":["{{id}}"]}""").Collect(Room); var first = await db.KeepsakeCollectionRecords.SingleAsync(); await CreateApi(db, body: $$"""{"KeepsakeInstanceIds":["{{id}}"]}""").Collect(Room); var records = db.KeepsakeCollectionRecords.ToList(); Assert.Single(records); Assert.Equal(first.CollectedAt, records[0].CollectedAt); } /// /// Another player's collection is not mine: their records do not hide an instance from me. /// [Fact] public async Task CollectionIsPerPlayer() { await using var db = NewDb(); var id = Guid.NewGuid(); await CreateApi(db, accountId: 2, body: $$"""{"KeepsakeCategoryConfigId":0,"KeepsakeInstanceId":"{{id}}"}""").Place(Room); await CreateApi(db, accountId: 2, body: $$"""{"KeepsakeInstanceIds":["{{id}}"]}""").Collect(Room); var result = Assert.IsType(await CreateApi(db, accountId: 3).Room(Room)); var json = JsonSerializer.SerializeToElement(result.Value); Assert.Single(json.GetProperty("KeepsakeInstances").EnumerateArray()); Assert.Empty(json.GetProperty("KeepsakeCollectionRecords").EnumerateArray()); } /// /// An unnamed keepsake call is named in the log and answered 404, never 204: a write that looks /// successful while the room save holds an id nothing backs is the failure this all exists to /// avoid, and the log line is how the spelling gets found. /// [Fact] public async Task UnclaimedRoutesAreReportedAndRefused() { await using var db = NewDb(); var api = CreateApi(db, body: """{"KeepsakeInstanceIds":[]}"""); Assert.IsType(await api.Unclaimed("rooms/25/whatever")); } /// /// Deleted keepsakes stay in the table but stop being served. The override pins "no keepsake /// rooms" so this reads the removed instance and not the shipped config, which gives every /// room the permission and would otherwise answer this room with an empty list instead. /// [Fact] public async Task RemovedInstancesAreNotServed() { await using var db = NewDb(); var id = Guid.NewGuid(); await CreateApi(db, body: $$"""{"KeepsakeCategoryConfigId":4,"KeepsakeInstanceId":"{{id}}"}""").Place(Room); var instance = await db.KeepsakeInstances.SingleAsync(); instance.Removed = true; await db.SaveChangesAsync(); var original = KeepsakeRoomConfig.Override; try { KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse(null); Assert.IsType(await CreateApi(db).Room(Room)); } finally { KeepsakeRoomConfig.Override = original; } } /// /// The same removed instance in a room the server DOES advertise as a keepsake room: the room /// fields come back - the permission the shipped config grants - and the deleted instance is /// still absent from them. /// [Fact] public async Task ARemovedInstanceIsNotServedEvenInAKeepsakeRoom() { await using var db = NewDb(); var id = Guid.NewGuid(); await CreateApi(db, body: $$"""{"KeepsakeCategoryConfigId":4,"KeepsakeInstanceId":"{{id}}"}""").Place(Room); var instance = await db.KeepsakeInstances.SingleAsync(); instance.Removed = true; await db.SaveChangesAsync(); var original = KeepsakeRoomConfig.Override; try { KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""); var ok = Assert.IsType(await CreateApi(db).Room(Room)); var json = JsonSerializer.SerializeToElement(ok.Value); Assert.Empty(json.GetProperty("KeepsakeInstances").EnumerateArray()); Assert.Equal(Room, json.GetProperty("KeepsakeRoomId").GetInt64()); } finally { KeepsakeRoomConfig.Override = original; } } [Fact] public void CatalogCarriesTheReferenceCategories() { var results = KeepsakeCatalog.Results(); Assert.Equal(5, results.Length); Assert.Equal("explore", KeepsakeCatalog.Find(0)!.VisualId); Assert.Equal(3, KeepsakeCatalog.Find(0)!.LimitPerRoom); Assert.Equal(40, KeepsakeCatalog.Find(6)!.XpValue); Assert.Null(KeepsakeCatalog.Find(99)); } [Fact] public async Task CollectionAcknowledgesOnlyExistingActiveInstancesInThisRoom() { await using var db = NewDb(); var id = PlacedGuid(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); var other = PlacedGuid(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room + 1)); var result = Assert.IsType(await CreateApi(db, body: $$"""{"InstanceIds":["{{id}}","{{id}}","{{other}}","{{Guid.NewGuid()}}"]}""").Collect(Room)); Assert.Equal(id, Assert.Single(JsonSerializer.SerializeToElement(result.Value) .GetProperty("KeepsakeInstanceIds").EnumerateArray()).GetGuid()); Assert.Single(db.KeepsakeCollectionRecords); } [Fact] public async Task RemovalIsRepeatablePreservesHistoryAndPreventsFurtherCollection() { await using var db = NewDb(); var id = PlacedGuid(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); var body = $$"""{"InstanceIds":["{{id}}"]}"""; await CreateApi(db, body: body).Collect(Room); Assert.IsType(await CreateApi(db, accountId: 3).Remove(Room, id)); Assert.IsType(await CreateApi(db).Remove(Room + 1, id)); Assert.IsType(await CreateApi(db).Remove(Room, id)); Assert.IsType(await CreateApi(db).Remove(Room, id)); await CreateApi(db, accountId: 3, body: body).Collect(Room); Assert.Single(db.KeepsakeCollectionRecords); Assert.True((await db.KeepsakeInstances.SingleAsync()).Removed); } [Fact] public async Task PlacementCannotTakeAnotherPlayersInstanceOrMoveItToAnotherRoom() { await using var db = NewDb(); var id = PlacedGuid(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); var body = $$"""{"CategoryId":0,"KeepsakeInstanceId":"{{id}}"}"""; Assert.IsType(await CreateApi(db, accountId: 3, body: body).Place(Room)); Assert.IsType(await CreateApi(db, body: body).Place(Room + 1)); Assert.Equal(Room, (await db.KeepsakeInstances.SingleAsync()).RoomId); } [Fact] public async Task RoomAndCategoryLimitsAllowRetriesButRejectAdditionalPlacements() { await using var db = NewDb(); KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{"AllRooms":true,"RoomLimit":1}"""); var id = PlacedGuid(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); Assert.IsType(await CreateApi(db, body: """{"CategoryId":4}""").Place(Room)); Assert.Equal(id, PlacedGuid(await CreateApi(db, body: $$"""{"CategoryId":0,"KeepsakeInstanceId":"{{id}}"}""").Place(Room))); KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{"AllRooms":true,"RoomLimit":10}"""); await CreateApi(db, body: """{"CategoryId":0}""").Place(Room); await CreateApi(db, body: """{"CategoryId":0}""").Place(Room); Assert.IsType(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); } [Fact] public async Task ValidBodyLargerThanLogPreviewIsParsedInFull() { await using var db = NewDb(); var body = JsonSerializer.Serialize(new { Padding = new string('x', 2100), CategoryId = 0 }); PlacedGuid(await CreateApi(db, body: body).Place(Room)); Assert.Single(db.KeepsakeInstances); } [Fact] public async Task PlacementRequiresAnAccountAndAPermittedRoom() { await using var db = NewDb(); var api = CreateApi(db, body: """{"CategoryId":0}"""); api.HttpContext.User = new ClaimsPrincipal(); Assert.IsType(await api.Place(Room)); KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{"RoomIds":[26]}"""); Assert.IsType(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); Assert.Empty(db.KeepsakeInstances); } [Fact] public async Task SeptemberClientCanPlaceReadCollectAndDeleteUsingItsActualRoutes() { await using var db = NewDb(); var placed = Assert.IsType(await CreateApi(db, body: $$"""{"RoomId":{{Room}},"SubRoomId":null,"KeepsakeCategory":4}""").PlaceFromClient()); var id = Assert.IsType(placed.Value); var room = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).Room(Room)).Value); Assert.Equal(id, Assert.Single(room.GetProperty("Instances").EnumerateArray()).GetProperty("KeepsakeInstanceId").GetGuid()); var eventId = KeepsakeRoomConfig.Current.EventId; var before = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).EventInstances(eventId)).Value); Assert.Equal(Room, Assert.Single(before.GetProperty("Instances").EnumerateArray()).GetProperty("RoomId").GetInt64()); Assert.Empty(before.GetProperty("CollectionRecords").EnumerateArray()); var reward = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).CollectFromClient(id)).Value); Assert.Equal(20, reward.GetProperty("TotalXp").GetInt32()); reward = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).CollectFromClient(id)).Value); Assert.Equal(0, reward.GetProperty("TotalXp").GetInt32()); Assert.Single(db.KeepsakeCollectionRecords); room = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).Room(Room)).Value); Assert.Single(room.GetProperty("Instances").EnumerateArray()); Assert.Equal(2, Assert.Single(room.GetProperty("CollectionRecords").EnumerateArray()).GetProperty("AccountId").GetInt64()); var after = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).EventInstances(eventId)).Value); Assert.Equal(id, Assert.Single(Assert.Single(after.GetProperty("CollectionRecords").EnumerateArray()) .GetProperty("KeepsakeInstanceIds").EnumerateArray()).GetGuid()); var other = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db, accountId: 3).EventInstances(eventId)).Value); Assert.Empty(other.GetProperty("CollectionRecords").EnumerateArray()); Assert.IsType(await CreateApi(db).RemoveFromClient(id)); var removed = Assert.IsType(await CreateApi(db).CollectFromClient(id)); Assert.Equal(404, removed.StatusCode); Assert.Equal("The keepsake instance has been removed.", Assert.IsType(removed.Value).Detail); } [Fact] public async Task ClientCollectionExplainsMissingAndDisabledInstancesWithoutWritingRecords() { await using var db = NewDb(); var id = Guid.NewGuid(); var missing = Assert.IsType(await CreateApi(db).CollectFromClient(id)); Assert.Equal(404, missing.StatusCode); Assert.Equal("The keepsake instance is not registered on this server.", Assert.IsType(missing.Value).Detail); KeepsakeFeature.Override = false; var disabled = Assert.IsType(await CreateApi(db).CollectFromClient(id)); Assert.Equal(404, disabled.StatusCode); Assert.Equal("Keepsakes are disabled on this server.", Assert.IsType(disabled.Value).Detail); Assert.Empty(db.KeepsakeCollectionRecords); } [Fact] public async Task EmptyRoomAndEventHaveNonNullClientLists() { await using var db = NewDb(); foreach (var response in new[] { await CreateApi(db).Room(Room), await CreateApi(db).EventInstances(KeepsakeRoomConfig.Current.EventId) }) { var json = JsonSerializer.SerializeToElement(Assert.IsType(response).Value); Assert.Empty(json.GetProperty("Instances").EnumerateArray()); Assert.Empty(json.GetProperty("CollectionRecords").EnumerateArray()); } Assert.IsType(await CreateApi(db).EventInstances(9999)); } [Fact] public async Task EventCanBeDisabledWhileKeepsakesRemainEnabled() { KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{"AllRooms":true,"EventEnabled":false}"""); await using var db = NewDb(); Assert.True(KeepsakeFeature.Enabled); var api = new RadicadeServer.API.Compatibility.CompatibilityAPI(db); Assert.Equal("null", Assert.IsType(api.ActiveProgressionEvents()).Content); Assert.IsType(api.ProgressionEventDefinition(1)); Assert.IsType(await CreateApi(db).EventInstances(1)); PlacedGuid(await CreateApi(db, body: """{"CategoryId":0}""").Place(Room)); var room = JsonSerializer.SerializeToElement(Assert.IsType(await CreateApi(db).Room(Room)).Value); Assert.Empty(room.GetProperty("KeepsakeProgressionEventIds").EnumerateArray()); Assert.Single(room.GetProperty("Instances").EnumerateArray()); } [Fact] public async Task AllRoomsEventAdvertisesRealRoomObjectsAndPlayerRecordTracksCollectionXp() { await using var db = NewDb(); db.Rooms.Add(new RadicadeServer.RecNet.Rooms.RoomData { RoomId = Room, Name = "Keepsake test" }); await db.SaveChangesAsync(); var keepsakes = CreateApi(db, body: """{"KeepsakeCategory":6}"""); var id = PlacedGuid(await keepsakes.Place(Room)); await CreateApi(db).CollectFromClient(id); var api = new RadicadeServer.API.Compatibility.CompatibilityAPI(db) { ControllerContext = CreateApi(db).ControllerContext }; var definition = JsonSerializer.SerializeToElement(Assert.IsType(api.ProgressionEventDefinition(1)).Value); var list = Assert.Single(definition.GetProperty("KeepsakeRoomLists").EnumerateArray()); var room = Assert.Single(list.GetProperty("KeepsakeRooms").EnumerateArray()); Assert.Equal(Room, room.GetProperty("RoomId").GetInt64()); Assert.Equal(1, room.GetProperty("KeepsakeRoomListId").GetInt64()); Assert.Equal(1, list.GetProperty("ProgressionEventId").GetInt64()); Assert.True(list.GetProperty("RoomUnlockIntervalTicks").GetInt64() > 0); var record = JsonSerializer.SerializeToElement(Assert.IsType(api.ProgressionEventRecord(1)).Value); Assert.Equal(2, record.GetProperty("AccountId").GetInt64()); Assert.Equal(40, record.GetProperty("Xp").GetInt32()); } }