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