using System.Reflection; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Avatars.V1; using RadicadeServer.API.Avatars.V2; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Avatar; using Xunit; namespace RadicadeServer.Tests; /// Inventory reads expose starter defaults and owned items, never the full catalogs. [Collection(DbFactoryCollection.Name)] public class EquipmentInventoryTests { [Theory] [InlineData("Unlocked Locker", true)] [InlineData("moderator, unlocked locker", true)] [InlineData("developer", false)] [InlineData("Not Unlocked Locker", false)] [InlineData(null, false)] public async Task LockerRoleControlsAllThreeInventoriesWithoutPermanentlyGrantingItems(string? role, bool full) { var oldAvatar = DBConfig.DefaultAvatarCatalog; var oldEquipment = DBConfig.DefaultEquipmentItems; var oldSkins = DBConfig.DefaultEquipmentSkinItems; var oldConsumables = DBConfig.DefaultConsumableItems; var oldLookup = DBConfig.ResolvableAvatarItemDescs; try { DBConfig.DefaultAvatarCatalog = """[{"AvatarItemDesc":"role-exclusive,","FriendlyName":"Role item"}]"""; DBConfig.ResolvableAvatarItemDescs = new HashSet(); DBConfig.DefaultEquipmentItems = """[{"PrefabName":"[PaintballGun]","ModificationGuid":"role-skin"}]"""; DBConfig.DefaultEquipmentSkinItems = "[]"; DBConfig.DefaultConsumableItems = ShipCatalog("consumables.json"); using var db = NewDb(); var profile = new RadicadeServer.RecNet.Auth.Profile { accountId = 7, username = "locker", displayName = "Locker", profileImage = "default", role = role }; db.Profiles.Add(profile); db.Unlocks.Add(new PlayerUnlocks { AccountId = 7, AvatarItems = "[]" }); await db.SaveChangesAsync(); var avatar = new RadicadeServer.API.Avatars.V4.AvatarV4(db); var equipment = new EquipmentV2(db); var consumables = new ConsumablesV2(db); SetUser(avatar, 7); SetUser(equipment, 7); SetUser(consumables, 7); var items = Assert.IsType>(Assert.IsType((await avatar.GetLocalAvatarItems()).Result).Value); Assert.Equal(full, items.Any(item => item.AvatarItemDesc == "role-exclusive,")); Assert.Equal(full, equipment.GetUnlockedEquipment().Any(item => item.ModificationGuid == "role-skin")); Assert.Equal(full, (await consumables.GetUnlockedConsumables())!.Any()); profile.role = null; await db.SaveChangesAsync(); items = Assert.IsType>(Assert.IsType((await avatar.GetLocalAvatarItems()).Result).Value); Assert.DoesNotContain(items, item => item.AvatarItemDesc == "role-exclusive,"); Assert.Empty(equipment.GetUnlockedEquipment()); Assert.Empty((await consumables.GetUnlockedConsumables())!); var stored = await db.Unlocks.AsNoTracking().SingleAsync(); Assert.Empty(stored.GetAvatarItems()); Assert.Empty(stored.GetEquipment()); Assert.Empty(stored.GetConsumables()); } finally { DBConfig.DefaultAvatarCatalog = oldAvatar; DBConfig.DefaultEquipmentItems = oldEquipment; DBConfig.DefaultEquipmentSkinItems = oldSkins; DBConfig.DefaultConsumableItems = oldConsumables; DBConfig.ResolvableAvatarItemDescs = oldLookup; } } [Fact] public async Task QuestAvatarInventoryDoesNotReturnDescriptorsMissingFromTheClientBuild() { var originalLookup = DBConfig.ResolvableAvatarItemDescs; try { using var lookup = JsonDocument.Parse(ShipCatalog("avatar-item-lookup.json")); DBConfig.ResolvableAvatarItemDescs = lookup.RootElement.GetProperty("descriptions") .EnumerateArray().Select(element => element.GetString()!) .ToHashSet(StringComparer.OrdinalIgnoreCase); const string unsupportedHairDye = "yTdx1nVVtECU8Vh7EC8dGA"; Assert.DoesNotContain(unsupportedHairDye, DBConfig.ResolvableAvatarItemDescs); var supportedDescription = DBConfig.ResolvableAvatarItemDescs.First(); using var db = NewDb(); var unlocks = new PlayerUnlocks { AccountId = 7, AvatarItems = JsonSerializer.Serialize(new[] { new AvatarItem { AvatarItemDesc = supportedDescription, FriendlyName = "Supported" }, new AvatarItem { AvatarItemDesc = unsupportedHairDye, FriendlyName = "Unsupported" } }) }; db.Unlocks.Add(unlocks); await db.SaveChangesAsync(); var api = new RadicadeServer.API.Avatars.V4.AvatarV4(db); SetUser(api, 7, (int)RadicadeServer.RecNet.Enums.Players.Platforms.Standalone); var result = Assert.IsType>( Assert.IsType((await api.GetLocalAvatarItems()).Result).Value); Assert.Contains(result, item => item.AvatarItemDesc == supportedDescription && item.FriendlyName == "Supported"); Assert.DoesNotContain(result, item => item.AvatarItemDesc == unsupportedHairDye); SetUser(api, 7, (int)RadicadeServer.RecNet.Enums.Players.Platforms.Oculus); result = Assert.IsType>( Assert.IsType((await api.GetLocalAvatarItems()).Result).Value); Assert.Contains(result, item => item.AvatarItemDesc == unsupportedHairDye); } finally { DBConfig.ResolvableAvatarItemDescs = originalLookup; } } [Fact] public async Task NewInventoryContainsStarterClothingButNoCatalogSkinsOrConsumables() { var originalCatalog = DBConfig.DefaultAvatarCatalog; var originalLookup = DBConfig.ResolvableAvatarItemDescs; try { DBConfig.DefaultAvatarCatalog = ShipCatalog("2022-avatar.json"); using var lookup = JsonDocument.Parse(ShipCatalog("avatar-item-lookup.json")); DBConfig.ResolvableAvatarItemDescs = lookup.RootElement.GetProperty("descriptions") .EnumerateArray().Select(x => x.GetString()!).ToHashSet(StringComparer.OrdinalIgnoreCase); var starters = AvatarUnlockCatalog.GetStarterItems(); Assert.NotEmpty(starters); Assert.True(starters.Count < AvatarUnlockCatalog.ParseCatalog(DBConfig.DefaultAvatarCatalog).Count); Assert.All(starters, item => Assert.Contains(item.AvatarItemDesc, DBConfig.ResolvableAvatarItemDescs)); var allowed = AvatarUnlockCatalog.ParseCatalog(AvatarUnlockCatalog.LoadStarterJson()) .Select(x => x.AvatarItemDesc).ToHashSet(); Assert.All(starters, item => Assert.True(item.IsBaseAvatarItem || allowed.Contains(item.AvatarItemDesc))); using var db = NewDb(); var inventory = new PlayerUnlocks { AccountId = 7, AvatarItems = starters.ToJson() }; db.Unlocks.Add(inventory); await db.SaveChangesAsync(); var equipment = new EquipmentV2(db); SetUser(equipment, 7); Assert.Empty(equipment.GetUnlockedEquipment()); var consumables = new ConsumablesV2(db); SetUser(consumables, 7); Assert.Empty((await consumables.GetUnlockedConsumables())!); Assert.Empty(inventory.GetEquipment()); Assert.Empty(inventory.GetConsumables()); } finally { DBConfig.DefaultAvatarCatalog = originalCatalog; DBConfig.ResolvableAvatarItemDescs = originalLookup; } } [Fact] public async Task ConsumingLastOwnedItemDoesNotRestoreItFromTheCatalog() { using var db = NewDb(); var inventory = new PlayerUnlocks { AccountId = 7 }; inventory.AddConsumable(new Consumable { Id = 42, ConsumableItemDesc = "Root Beer", Count = 1, InitialCount = 1 }); db.Unlocks.Add(inventory); await db.SaveChangesAsync(); var api = new ConsumablesV2(db); SetUser(api, 7); api.Request.ContentType = "application/json"; api.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"Id\":42,\"DeltaCount\":1}")); Assert.IsType(await api.ConsumeConsumable()); db.ChangeTracker.Clear(); Assert.Empty((await api.GetUnlockedConsumables())!); Assert.Empty((await db.Unlocks.SingleAsync()).GetConsumables()); } private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name); private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static void SetUser(Controller controller, long accountId, int? platform = null) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); if (platform.HasValue) identity.AddClaim(new Claim("rn.plat", platform.Value.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static string ShipCatalog(string fixture) => File.ReadAllText(Fixture(fixture)); [Fact] public void TheEquipmentUpdateRouteAnswersUnderBothPrefixes() { var routes = typeof(EquipmentV1).GetCustomAttributes() .Select(route => route.Template).ToList(); // The client calls it under `econ/`, like every other avatars route. Without this the // request 404s even though the method is right there. Assert.Contains("econ/api/equipment/v1", routes); Assert.Contains("api/equipment/v1", routes); } [Fact] public void TheEquipmentUpdateRouteAnswersBothVerbs() { var method = typeof(EquipmentV1).GetMethod(nameof(EquipmentV1.UpdateEquipment))!; var posts = method.GetCustomAttributes().Select(a => a.Template).ToList(); var puts = method.GetCustomAttributes().Select(a => a.Template).ToList(); // The 2023 client POSTs; the reference implementation documents PUT. Accepting only one // is a bet, and the whole point of this endpoint is that it used to answer nothing. Assert.Contains("update", posts); Assert.Contains("update", puts); } [Fact] public async Task AFavouritedOwnedItemIsStoredAndServedBack() { var previous = DBConfig.DefaultEquipmentItems; try { DBConfig.DefaultEquipmentItems = ShipCatalog("equipment.json"); using var db = NewDb(); var owned = new PlayerUnlocks { AccountId = 7 }; owned.AddEquipment(JsonSerializer.Deserialize>(DBConfig.DefaultEquipmentItems)![0]); db.Unlocks.Add(owned); await db.SaveChangesAsync(); var guid = JsonSerializer.Deserialize>(DBConfig.DefaultEquipmentItems)![0].ModificationGuid; var controller = new EquipmentV1(db); SetUser(controller, 7); var result = await controller.UpdateEquipment(JsonDocument.Parse( $"[{{\"ModificationGuid\":\"{guid}\",\"Favorited\":true}}]").RootElement); Assert.IsType(result); var stored = (await db.Unlocks.FirstAsync(x => x.AccountId == 7)).GetEquipment()!; var row = Assert.Single(stored); Assert.Equal(guid, row.ModificationGuid); Assert.True(row.Favorited); // And the flag has to survive the trip back out: getUnlocked merges the player's rows // over the catalog, so the row above is what makes the favourite stick. var readback = new EquipmentV2(db); SetUser(readback, 7); var served = readback.GetUnlockedEquipment(); var servedRow = Assert.Single(served, item => item.ModificationGuid == guid); Assert.True(servedRow.Favorited); Assert.Equal(served.Count, served.Select(item => item.ModificationGuid).Distinct().Count()); } finally { DBConfig.DefaultEquipmentItems = previous; } } [Fact] public async Task AFavouriteOnAnAccountWithNoUnlockRowDoesNotGrantTheSkin() { var previous = DBConfig.DefaultEquipmentItems; try { DBConfig.DefaultEquipmentItems = ShipCatalog("equipment.json"); // Accounts in the wild exist without an unlock row - one turned up on the live // database while this was being written. Without creating the row the write is // accepted, dropped, and served back as an un-favourite. using var db = NewDb(); Assert.Empty(db.Unlocks); var guid = JsonSerializer.Deserialize>(DBConfig.DefaultEquipmentItems)![0].ModificationGuid; var controller = new EquipmentV1(db); SetUser(controller, 7); Assert.IsType(await controller.UpdateEquipment(JsonDocument.Parse( $"[{{\"ModificationGuid\":\"{guid}\",\"Favorited\":true}}]").RootElement)); var unlocks = await db.Unlocks.FirstAsync(x => x.AccountId == 7); Assert.Empty(unlocks.GetEquipment()); } finally { DBConfig.DefaultEquipmentItems = previous; } } [Fact] public async Task UnfavouritingAnUntouchedItemStoresNothing() { var previous = DBConfig.DefaultEquipmentItems; try { DBConfig.DefaultEquipmentItems = ShipCatalog("equipment.json"); using var db = NewDb(); db.Unlocks.Add(new PlayerUnlocks { AccountId = 7 }); await db.SaveChangesAsync(); var guid = JsonSerializer.Deserialize>(DBConfig.DefaultEquipmentItems)![0].ModificationGuid; var controller = new EquipmentV1(db); SetUser(controller, 7); var result = await controller.UpdateEquipment(JsonDocument.Parse( $"[{{\"ModificationGuid\":\"{guid}\",\"Favorited\":false}}]").RootElement); Assert.IsType(result); // Nothing to un-favourite means nothing to write. Otherwise every entry the client // echoes would leave a row behind and a profile would grow a copy of the catalog. Assert.Equal("[]", (await db.Unlocks.FirstAsync(x => x.AccountId == 7)).Equipment); } finally { DBConfig.DefaultEquipmentItems = previous; } } [Fact] public async Task AMalformedUpdateBodyIsRefused() { using var db = NewDb(); var controller = new EquipmentV1(db); SetUser(controller, 7); Assert.IsType( await controller.UpdateEquipment(JsonDocument.Parse("\"nope\"").RootElement)); Assert.IsType( await controller.UpdateEquipment(JsonDocument.Parse("{\"other\":[]}").RootElement)); } [Fact] public void EveryShippedConsumableIsGrantedAsAClientToken() { var granted = ConsumableCatalog.DefaultInstances(ShipCatalog("consumables.json")); Assert.Equal(87, granted.Count); Assert.DoesNotContain(granted, item => string.IsNullOrWhiteSpace(item.ConsumableItemDesc)); // A descriptor that is still a friendly name updates nothing in the client: it resolves // consumables by token and logs "Unable to find consumable" on a miss. Assert.DoesNotContain(granted, item => item.ConsumableItemDesc.Contains(' ')); Assert.Equal(granted.Count, granted.Select(item => item.Id).Distinct().Count()); Assert.All(granted, item => { Assert.Equal(ConsumableCatalog.DefaultCount, item.Count); Assert.Equal(ConsumableCatalog.DefaultCount, item.InitialCount); Assert.InRange(item.Id, 800_000_000L, 899_999_999L); }); } [Fact] public void AGrantedIdIsTheSameOnEveryBoot() { var first = ConsumableCatalog.DefaultInstances(ShipCatalog("consumables.json")); var second = ConsumableCatalog.DefaultInstances(ShipCatalog("consumables.json")); // Pinned so nobody swaps FNV for string.GetHashCode(), which is salted per process: the // ids would change on restart and the client would see a different inventory each boot. Assert.Equal( first.ToDictionary(item => item.ConsumableItemDesc, item => item.Id), second.ToDictionary(item => item.ConsumableItemDesc, item => item.Id)); } [Fact] public async Task TheCatalogIsNotGrantedToAPlayerWhoOwnsNothing() { var previous = DBConfig.DefaultConsumableItems; try { DBConfig.DefaultConsumableItems = ShipCatalog("consumables.json"); using var db = NewDb(); db.Unlocks.Add(new PlayerUnlocks { AccountId = 7 }); await db.SaveChangesAsync(); var controller = new ConsumablesV2(db); SetUser(controller, 7); var served = await controller.GetUnlockedConsumables(); Assert.NotNull(served); Assert.Empty(served!); Assert.Equal(served.Count, served.Select(item => item.ConsumableItemDesc).Distinct().Count()); Assert.All(served, item => { Assert.NotEmpty(item.Ids); // Ids and CreatedAts are parallel arrays to the client; a short one is a malformed // response, not just an odd one. Assert.Equal(item.Ids.Count, item.CreatedAts.Count); Assert.True(item.Count > 0); }); } finally { DBConfig.DefaultConsumableItems = previous; } } [Fact] public async Task OnlyOwnedConsumableInstancesAreServed() { var previous = DBConfig.DefaultConsumableItems; try { DBConfig.DefaultConsumableItems = ShipCatalog("consumables.json"); var token = ConsumableCatalog.ToClientDesc("Root Beer"); using var db = NewDb(); var unlocks = new PlayerUnlocks { AccountId = 7 }; unlocks.AddConsumable(new Consumable { Id = 42, ConsumableItemDesc = "Root Beer", Count = 2, InitialCount = 2, CreatedAt = new DateTime(2024, 5, 1, 0, 0, 0, DateTimeKind.Utc) }); db.Unlocks.Add(unlocks); await db.SaveChangesAsync(); var controller = new ConsumablesV2(db); SetUser(controller, 7); var served = (await controller.GetUnlockedConsumables())!; var rootBeer = Assert.Single(served, item => item.ConsumableItemDesc == token); // What they really have, not the grant plus their purchase - otherwise buying a second // one would look like it did nothing. Assert.Equal(new List { 42 }, rootBeer.Ids); Assert.Equal(2, rootBeer.Count); Assert.Single(served); } finally { DBConfig.DefaultConsumableItems = previous; } } [Fact] public async Task TwoInstancesOfOneItemAreGroupedIntoOneEntry() { var previous = DBConfig.DefaultConsumableItems; try { DBConfig.DefaultConsumableItems = ShipCatalog("consumables.json"); using var db = NewDb(); var unlocks = new PlayerUnlocks { AccountId = 7 }; unlocks.AddConsumable(new Consumable { Id = 11, ConsumableItemDesc = "Bubbly", Count = 1, InitialCount = 1 }); unlocks.AddConsumable(new Consumable { Id = 12, ConsumableItemDesc = "Bubbly", Count = 3, InitialCount = 3 }); db.Unlocks.Add(unlocks); await db.SaveChangesAsync(); var controller = new ConsumablesV2(db); SetUser(controller, 7); var served = (await controller.GetUnlockedConsumables())!; var bubbly = Assert.Single(served, item => item.ConsumableItemDesc == ConsumableCatalog.ToClientDesc("Bubbly")); Assert.Equal(new List { 11, 12 }, bubbly.Ids); Assert.Equal(4, bubbly.Count); } finally { DBConfig.DefaultConsumableItems = previous; } } }