mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
484 lines
21 KiB
C#
484 lines
21 KiB
C#
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;
|
|
|
|
/// <summary>Inventory reads expose starter defaults and owned items, never the full catalogs.</summary>
|
|
[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<string>();
|
|
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<List<AvatarItemV4>>(Assert.IsType<OkObjectResult>((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<List<AvatarItemV4>>(Assert.IsType<OkObjectResult>((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<List<AvatarItemV4>>(
|
|
Assert.IsType<OkObjectResult>((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<List<AvatarItemV4>>(
|
|
Assert.IsType<OkObjectResult>((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<OkResult>(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<RouteAttribute>()
|
|
.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<HttpPostAttribute>().Select(a => a.Template).ToList();
|
|
var puts = method.GetCustomAttributes<HttpPutAttribute>().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<List<Equipment>>(DBConfig.DefaultEquipmentItems)![0]);
|
|
db.Unlocks.Add(owned);
|
|
await db.SaveChangesAsync();
|
|
|
|
var guid = JsonSerializer.Deserialize<List<Equipment>>(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<OkResult>(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<List<Equipment>>(DBConfig.DefaultEquipmentItems)![0].ModificationGuid;
|
|
var controller = new EquipmentV1(db);
|
|
SetUser(controller, 7);
|
|
|
|
Assert.IsType<OkResult>(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<List<Equipment>>(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<OkResult>(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<BadRequestResult>(
|
|
await controller.UpdateEquipment(JsonDocument.Parse("\"nope\"").RootElement));
|
|
Assert.IsType<BadRequestResult>(
|
|
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<long> { 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<long> { 11, 12 }, bubbly.Ids);
|
|
Assert.Equal(4, bubbly.Count);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.DefaultConsumableItems = previous;
|
|
}
|
|
}
|
|
}
|