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());
}
}