using System.Security.Claims; 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; /// /// Keepsake rooms: which rooms the server tells the client it may place keepsakes in. /// /// The client's room-role permissions have no keepsake entry and its room DTO carries no keepsake /// field, so the room's KeepsakeRoomListId - the thing it reports as missing with /// "This room does not have keepsake permissions." - can only arrive with /// GET /api/keepsakes/rooms/{id}. A configured room therefore answers with the room fields /// even while it holds nothing, and an unconfigured one keeps the old NO CONTENT. /// public class KeepsakeRoomConfigTests { private const long Room = 25; private static RecDBContext NewDb() => new RecDBContext(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); private static KeepsakesAPI CreateApi(RecDBContext? db) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "2")); var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; return new KeepsakesAPI(db!) { ControllerContext = new ControllerContext { HttpContext = context } }; } /// /// Runs one endpoint call against a config. The seam is process-wide and the endpoint reads it /// per call, so the override only has to be in place for the call itself. /// private static async Task WithConfigAsync(KeepsakeRoomConfig config, Func> call) { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = config; try { return await call(); } finally { KeepsakeRoomConfig.Override = previous; } } [Fact] public void NothingConfiguredIsNothingConfigured() { var config = KeepsakeRoomConfig.Parse(null); Assert.False(config.Any); Assert.False(config.IsKeepsakeRoom(Room)); Assert.Equal(KeepsakeRoomConfig.DefaultRoomListId, config.RoomListId); Assert.Equal(KeepsakeRoomConfig.DefaultRoomLimit, config.RoomLimit); } [Fact] public void ExplicitEnvironmentRoomsReplaceAllRoomsFromFile() { var config = KeepsakeRoomConfig.Parse("""{"AllRooms":true}""", rooms: "25"); Assert.True(config.IsKeepsakeRoom(25)); Assert.False(config.IsKeepsakeRoom(26)); } [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(4294967297L)] public void InvalidRoomLimitsUseDefault(long limit) { var config = KeepsakeRoomConfig.Parse($$"""{"RoomLimit":{{limit}}}"""); Assert.Equal(KeepsakeRoomConfig.DefaultRoomLimit, config.RoomLimit); } [Fact] public void AllRoomsFlagMakesEveryRoomAKeepsakeRoom() { var config = KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""); Assert.True(config.Any); Assert.True(config.IsKeepsakeRoom(1)); Assert.True(config.IsKeepsakeRoom(Room)); // No useful list of "every room" exists, so the payload advertises the room asked about. Assert.Equal(new[] { Room }, config.AdvertisedRooms(Room)); } [Fact] public void RoomIdsAcceptNumbersAndStrings() { var config = KeepsakeRoomConfig.Parse("""{ "RoomIds": [3, "25"], "RoomListId": 7, "RoomLimit": 5 }"""); Assert.True(config.IsKeepsakeRoom(3)); Assert.True(config.IsKeepsakeRoom(Room)); Assert.False(config.IsKeepsakeRoom(26)); Assert.Equal(7, config.RoomListId); Assert.Equal(5, config.RoomLimit); Assert.Equal(new long[] { 3, Room }, config.AdvertisedRooms(Room)); } [Fact] public void ABareArrayIsAListOfRooms() { var config = KeepsakeRoomConfig.Parse("[3, 25]"); Assert.True(config.IsKeepsakeRoom(3)); Assert.True(config.IsKeepsakeRoom(Room)); } /// /// A half-written file is "nothing configured", not a crash and not an enabled server: this /// switch decides whether rooms may hold keepsakes, so a typo must not turn it on. /// [Fact] public void AMalformedFileConfiguresNothing() { var config = KeepsakeRoomConfig.Parse("{ \"RoomIds\": [3,"); Assert.False(config.Any); Assert.False(config.IsKeepsakeRoom(3)); } /// /// The environment names the rooms INSTEAD of the file: an operator reaching for /// RADICADE_KEEPSAKE_ROOMS=* means "all of them", not "all of them plus this list". /// [Fact] public void TheEnvironmentReplacesTheFilesRooms() { var replaced = KeepsakeRoomConfig.Parse("""{ "RoomIds": [3] }""", rooms: "25;26"); Assert.False(replaced.IsKeepsakeRoom(3)); Assert.True(replaced.IsKeepsakeRoom(25)); Assert.True(replaced.IsKeepsakeRoom(26)); var all = KeepsakeRoomConfig.Parse("""{ "RoomIds": [3] }""", rooms: "*"); Assert.True(all.AllRooms); Assert.True(all.IsKeepsakeRoom(9999)); } [Fact] public void TheEnvironmentCarriesTheListIdAndLimit() { var config = KeepsakeRoomConfig.Parse(null, rooms: "25", roomListId: "9", roomLimit: "12"); Assert.Equal(9, config.RoomListId); Assert.Equal(12, config.RoomLimit); } /// /// A keepsake room answers with the room fields even while it holds nothing, and the answer /// still carries the three instance lists the client parses. /// [Fact] public async Task AConfiguredRoomAnswersTheRoomFields() { await using var db = NewDb(); var api = CreateApi(db); var result = await WithConfigAsync( KeepsakeRoomConfig.Parse("""{ "RoomIds": [25], "RoomListId": 7, "RoomLimit": 5 }"""), () => api.Room(Room)); var ok = Assert.IsType(result); var json = JsonSerializer.SerializeToElement(ok.Value); Assert.Equal(Room, json.GetProperty("KeepsakeRoomId").GetInt64()); Assert.Equal(7, json.GetProperty("KeepsakeRoomListId").GetInt64()); Assert.Equal(5, json.GetProperty("KeepsakeRoomLimit").GetInt32()); Assert.Equal(new long[] { Room }, json.GetProperty("KeepsakeRooms").EnumerateArray().Select(e => e.GetInt64()).ToArray()); // The list the client resolves has to contain the room it is loading. var list = Assert.Single(json.GetProperty("KeepsakeRoomLists").EnumerateArray()); Assert.Equal(7, list.GetProperty("KeepsakeRoomListId").GetInt64()); Assert.Equal(new long[] { Room }, list.GetProperty("KeepsakeRooms").EnumerateArray().Select(e => e.GetProperty("RoomId").GetInt64()).ToArray()); // Nothing is placed, so there is nothing for the keepsake loader to resolve - which is // what keeps this answer from being the body the client null-references over. Assert.Empty(json.GetProperty("KeepsakeInstances").EnumerateArray()); Assert.Empty(json.GetProperty("KeepsakeCollectionRecords").EnumerateArray()); // The room names the progression event its keepsakes belong to: that id is what the client // looks up among the active events before it offers a keepsake to place at all. Assert.Equal( new[] { KeepsakeRoomConfig.DefaultEventId }, json.GetProperty("KeepsakeProgressionEventIds").EnumerateArray().Select(e => e.GetInt64()).ToArray()); } [Fact] public async Task AnUnconfiguredRoomStillAnswersNoContent() { await using var db = NewDb(); var api = CreateApi(db); var result = await WithConfigAsync(KeepsakeRoomConfig.Parse("""{ "RoomIds": [999] }"""), () => api.Room(Room)); Assert.IsType(result); } /// A room that already holds a keepsake keeps its body whether or not it is listed - /// while the feature itself is on, which is forced here because a server with no keepsake room /// configured is a server with keepsakes disabled. [Fact] public async Task ARoomWithAPlacedKeepsakeKeepsItsBody() { await using var db = NewDb(); await db.KeepsakeInstances.AddAsync(new KeepsakeInstance { KeepsakeInstanceId = Guid.Parse("a75f7547-79eb-47c6-8986-6767abcb4f92"), RoomId = Room, KeepsakeCategoryId = 0, PlacedByAccountId = 2, PlacedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); var api = CreateApi(db); var previousFeature = KeepsakeFeature.Override; KeepsakeFeature.Override = true; try { var result = await WithConfigAsync(KeepsakeRoomConfig.Parse(null), () => api.Room(Room)); var ok = Assert.IsType(result); var json = JsonSerializer.SerializeToElement(ok.Value); Assert.Single(json.GetProperty("KeepsakeInstances").EnumerateArray()); Assert.False(json.TryGetProperty("KeepsakeRoomListId", out _)); } finally { KeepsakeFeature.Override = previousFeature; } } /// /// ...and with the feature OFF the same room answers NO CONTENT, keepsake and all. That is the /// point of the switch: a disabled server does not describe keepsakes to a client at all, so a /// room holding a placed keepsake is not the exception that still talks about one. /// [Fact] public async Task TheFeatureSwitchHidesEvenPlacedKeepsakes() { await using var db = NewDb(); await db.KeepsakeInstances.AddAsync(new KeepsakeInstance { KeepsakeInstanceId = Guid.NewGuid(), RoomId = Room, KeepsakeCategoryId = 0, PlacedByAccountId = 2, PlacedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); var previousFeature = KeepsakeFeature.Override; KeepsakeFeature.Override = false; try { var result = await WithConfigAsync(KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""), () => CreateApi(db).Room(Room)); Assert.IsType(result); } finally { KeepsakeFeature.Override = previousFeature; } } /// /// The file this repository ships gives every room the permission. That answer is the one the /// client reads off `GET /api/keepsakes/rooms/{id}` as "this room can hold keepsakes", so a /// checkout that shipped it switched off would keep the MakerPen banner the feature exists to /// clear. Narrowing it is an operator edit - `AllRooms: false` plus the ids in `RoomIds`. /// [Fact] public void TheShippedConfigEnablesKeepsakesAsRequested() { var path = ShippedConfigPath(); Assert.NotNull(path); var config = KeepsakeRoomConfig.Parse(File.ReadAllText(path!)); Assert.True(config.Any); Assert.True(config.AllRooms); Assert.True(config.IsKeepsakeRoom(25)); Assert.True(config.RoomLimit > 0); Assert.True(config.EventId > 0); } /// /// The feature switch follows that config: no keepsake room configured means the whole feature /// is off, which is the state the room, the catalog, the event and the client's own feature flag /// all read - see . RADICADE_KEEPSAKES is the operator's override of /// exactly that inference. /// [Fact] public void NoKeepsakeRoomMeansTheFeatureIsOff() { Assert.False(KeepsakeFeature.EnabledFor(KeepsakeRoomConfig.Parse(null), environment: null)); Assert.False(KeepsakeFeature.EnabledFor(KeepsakeRoomConfig.Parse(null), environment: "off")); Assert.True(KeepsakeFeature.EnabledFor(KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""), environment: null)); Assert.True(KeepsakeFeature.EnabledFor(KeepsakeRoomConfig.Parse("""{ "RoomIds": [25] }"""), environment: null)); Assert.False(KeepsakeFeature.EnabledFor(KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""), environment: "off")); Assert.True(KeepsakeFeature.EnabledFor(KeepsakeRoomConfig.Parse(null), environment: "on")); } /// /// The checked-in config, found from the test binaries rather than from the working directory: /// the test host's cwd is a bin folder, so the file the repository ships would otherwise go /// unread and this guard would silently pass. /// private static string? ShippedConfigPath() { var directory = new DirectoryInfo(AppContext.BaseDirectory); for (var depth = 0; depth < 6 && directory != null; depth++, directory = directory.Parent) { var candidate = Path.Join(directory.FullName, "Server", "RadicadeServer-Config", KeepsakeRoomConfig.FileName); if (File.Exists(candidate)) return candidate; } return null; } }