using System.Security.Claims; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using RadicadeServer.Admin; using RadicadeServer.API.Avatars.V2; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Avatar; using Xunit; namespace RadicadeServer.Tests; /// /// A gift of equipment is a gift of a *skin*, and a skin is two halves: the tool prefab and the /// modification guid that says which skin of it. The client resolves one from the pair /// (EquipmentSkinCatalog's own key, and what EquipmentCatalogTests pins as "a resolvable skin"), /// so a box carrying only the prefab is a box carrying the bare tool. /// /// The shipped catalog has nine rows for [Basketball] alone, all named "[Basketball]", one per /// skin. Two things followed from that, and this file is both of them: /// /// - the page the gift is set up on listed those nine rows as one choice, because every row /// arrived with the same prefab name and nothing to tell them apart. The panel keys that box /// by the value it gets here, so the catalogue has to name each row's guid. /// - the box the gift became held the prefab with an empty guid, whatever skin was picked, so /// the recipient was handed a plain basketball. ProcessGiftRowAsync hardcoded that guid to "". /// /// The tests drive the real route and then claim the box back through the client's own claim /// route, reading the equipment list the client reads to resolve a skin - so a skin that the /// catalogue can name but the box cannot carry fails here. public class AdminGiftSkinTests { private const string Secret = "test-bot-secret"; private const long Recipient = 114903603133502227; private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name); private static string ShippedCatalog() => File.ReadAllText(Fixture("equipment.json")); private static List Shipped() => JsonSerializer.Deserialize>(ShippedCatalog())!; /// A skin of [Basketball] that the shipped catalogue names, taken from it rather /// than written down here, so the test names a skin the client could actually resolve. private static Equipment ShippedBasketballSkin() => Shipped().First(row => row.PrefabName == "[Basketball]" && row.FriendlyName.Contains("Purple")); /* The in-memory store is not transactional, and the claim route wraps its work in a * transaction - which the provider reports as a warning raised as an error, so the route * throws before granting anything. The claim is the end of the chain these tests are here to * prove, so the warning is turned off rather than the route skipped: nothing about the store's * lack of transactions changes what a claimed box grants. */ private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .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) } }; } /// One catalogue row as the page receives it, whatever shape the route built it in. private static List RowsOf(IActionResult result) { var ok = Assert.IsType(result); return JsonNode.Parse(JsonSerializer.Serialize(ok.Value))!.AsArray() .Select(node => node!.AsObject()) .ToList(); } /// A catalogue, read through the route that serves it to the page. private static async Task> CatalogAsync(RecDBContext db, string table) { var originalKey = DBConfig.AdminApiKey; DBConfig.AdminApiKey = Secret; try { return RowsOf(await new AdminPanelAPI(db).List(table, Secret, null, null, null)); } finally { DBConfig.AdminApiKey = originalKey; } } /// Posts one gift_log row through the route, and returns the row it answered with. private static async Task GiveAsync(RecDBContext db, string rowJson) { var api = new AdminPanelAPI(db); var originalKey = DBConfig.AdminApiKey; var originalLogs = DBConfig.PersistentLogs; DBConfig.AdminApiKey = Secret; DBConfig.PersistentLogs = Path.Join(Path.GetTempPath(), "radicade-admin-skin-tests-" + Guid.NewGuid().ToString("N")); try { var payload = JsonDocument.Parse(rowJson).RootElement; var ok = Assert.IsType(await api.Insert("gift_log", Secret, payload)); return Assert.IsType(ok.Value); } finally { DBConfig.AdminApiKey = originalKey; DBConfig.PersistentLogs = originalLogs; } } /// The box as the recipient's client reads it: GET api/avatar/v2/gifts. private static async Task> ClientGiftsAsync(RecDBContext db, long playerId) { var api = new AvatarV2(db); SetUser(api, playerId); var ok = Assert.IsType(await api.GetGifts()); return Assert.IsAssignableFrom>(ok.Value).ToList(); } /// The recipient claims every box it holds, through POST api/avatar/v2/gifts/claim. private static async Task ClaimAsync(RecDBContext db, long playerId) { var api = new AvatarV2(db); SetUser(api, playerId); return await api.ClaimGifts(); } /// The equipment the client is served: GET api/equipment/v2/getUnlocked. private static List ServedEquipment(RecDBContext db, long playerId) { var api = new EquipmentV2(db); SetUser(api, playerId); return api.GetUnlockedEquipment(); } [Fact] public async Task TheEquipmentListNamesTheSkinOfEveryRow() { var previous = DBConfig.DefaultEquipmentItems; try { DBConfig.DefaultEquipmentItems = ShippedCatalog(); using var db = NewDb(); var rows = await CatalogAsync(db, "equipment_items"); // The rows are real, and the nine basketball ones are the one prefab: the name alone // cannot tell any of them from another, which is why one row used to answer for nine. Assert.NotEmpty(rows); var names = rows.Select(row => row["equipment_prefab_name"]!.GetValue()).ToList(); Assert.Equal(9, names.Count(name => name == "[Basketball]")); // So every row is named by its own guid, and no two rows share one - that is the value // the gift page keys its equipment box by, and one row there is one choice. var keys = rows.Select(row => row["modification_guid"]!.GetValue()).ToList(); Assert.All(keys, guid => Assert.False(string.IsNullOrWhiteSpace(guid))); Assert.Equal(keys.Count, keys.Distinct(StringComparer.OrdinalIgnoreCase).Count()); } finally { DBConfig.DefaultEquipmentItems = previous; } } [Fact] public async Task ThePickedSkinIsWhatTheBoxCarries() { var db = NewDb(); var skin = ShippedBasketballSkin(); // What the page posts for that row, both halves included. var row = await GiveAsync(db, $$""" { "player_id": "{{Recipient}}", "equip": "{{skin.PrefabName}}", "equipment_modification_guid": "{{skin.ModificationGuid}}", "message": "Here is your ball back.", "context": "Game_Drop", "rarity": "Uncommon" } """); Assert.Equal(true, row["sent"]?.GetValue()); // The panel's own record of what it did names the skin too. Assert.Equal(skin.ModificationGuid, row["equipment_modification_guid"]!.GetValue()); // The box itself, stored against the recipient: both halves of the skin, not the bare tool. var stored = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, Recipient)); Assert.Equal("[Basketball]", stored.EquipmentPrefabName); Assert.Equal(skin.ModificationGuid, stored.EquipmentModificationGuid); // And what the client reads off that stored box. var read = Assert.Single(await ClientGiftsAsync(db, Recipient)); Assert.Equal("[Basketball]", read.EquipmentPrefabName); Assert.Equal(skin.ModificationGuid, read.EquipmentModificationGuid); } /// One box of one skin, posted the way the page posts it. private static Task GiveSkinAsync(RecDBContext db, Equipment skin) => GiveAsync(db, $$""" { "player_id": "{{Recipient}}", "equip": "{{skin.PrefabName}}", "equipment_modification_guid": "{{skin.ModificationGuid}}", "context": "Game_Drop", "rarity": "Uncommon" } """); [Fact] public async Task ClaimingTheBoxHandsThePlayerThatSkin() { var db = NewDb(); var skin = ShippedBasketballSkin(); await GiveSkinAsync(db, skin); var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, Recipient)); Assert.IsType(await ClaimAsync(db, Recipient)); // The box is gone... Assert.DoesNotContain(await StorefrontShared.GetPendingGiftsAsync(db, Recipient), gift => gift.GiftDropId == box.GiftDropId); // ...and the skin is the player's own. That row is the part no catalogue can account for: // a shipped equipment list is merged into what the client is served either way, so the // gift arriving is only visible here, on the player. var unlocks = await db.Unlocks.FirstAsync(x => x.AccountId == Recipient); var owned = Assert.Single(unlocks.GetEquipment()!, item => item.PrefabName == skin.PrefabName); Assert.Equal(skin.ModificationGuid, owned.ModificationGuid); // And the pair is what the client is served from the list it resolves a skin from. Assert.Single(ServedEquipment(db, Recipient), item => item.PrefabName == skin.PrefabName && item.ModificationGuid == skin.ModificationGuid); } [Fact] public async Task ASkinOfAToolThePlayerAlreadyHasIsStillGiven() { var db = NewDb(); var basketball = Shipped().Where(row => row.PrefabName == "[Basketball]").Take(2).ToList(); var held = basketball[0]; var coming = basketball[1]; Assert.NotEqual(held.ModificationGuid, coming.ModificationGuid); // The player already has one skin of the tool, which is the ordinary case: the gift is // asked for by whoever set it up, and what they are adding is the skin, not the tool. var unlocks = new PlayerUnlocks { AccountId = Recipient }; unlocks.AddEquipment(new Equipment { PrefabName = held.PrefabName, ModificationGuid = held.ModificationGuid, FriendlyName = held.FriendlyName }); db.Unlocks.Add(unlocks); await db.SaveChangesAsync(); await GiveSkinAsync(db, coming); Assert.IsType(await ClaimAsync(db, Recipient)); // Both skins, not the first one twice: asking "do they have this?" by prefab alone // answered yes and threw the second skin away, with the box consumed and nothing said. var equipment = (await db.Unlocks.FirstAsync(x => x.AccountId == Recipient)).GetEquipment()!; var ownedSkins = equipment.Where(item => item.PrefabName == "[Basketball]").ToList(); Assert.Equal(2, ownedSkins.Count); Assert.Contains(ownedSkins, item => item.ModificationGuid == held.ModificationGuid); Assert.Contains(ownedSkins, item => item.ModificationGuid == coming.ModificationGuid); } [Fact] public async Task ABareToolIsStillJustTheTool() { var db = NewDb(); // A tool with no skin named is a tool with no skin. The route does not reach for a guid to // make the box look complete, because the first guid it found would be some other skin. await GiveAsync(db, $$""" { "player_id": "{{Recipient}}", "equip": "[Basketball]", "context": "Game_Drop", "rarity": "Common" } """); var stored = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, Recipient)); Assert.Equal("[Basketball]", stored.EquipmentPrefabName); Assert.Equal("", stored.EquipmentModificationGuid); } }