mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
7f507702b9
defaultbaseavataritems returned the first 100 catalog items. The 2023 client flags every row of that list as a custom-clothing template (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId) and skips templates when it rebuilds the owned outfit-menu entries, so those 100 ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even when owned and equipped, while the cart (which checks the owned set) said "owned". - defaultbaseavataritems returns only the UGC templates, UGCTee_Shirt (AvatarItemId 2184) and UGCPulloverHoodie_Shirt (2918), as the real service served them - those two keep the same ids in every list and carry IsBaseAvatarItem - they're no longer sold (existing shop rows are removed on seed) or listed as owned clothing
357 lines
18 KiB
C#
357 lines
18 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace DorkNet.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
|
|
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
|
|
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
|
|
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
|
|
/// id, so the second item collided, the unlocked-items list failed to build
|
|
/// (empty wardrobe = "no owned items"), and any refetch — including the one
|
|
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
|
|
/// ("can't buy items"). Each item must carry a unique, non-zero id.
|
|
/// </summary>
|
|
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
|
{
|
|
private readonly DorkNetServerFactory _factory;
|
|
|
|
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
|
|
|
|
[Fact]
|
|
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
|
|
|
|
var ids = new List<int>();
|
|
foreach (var it in items.EnumerateArray())
|
|
{
|
|
// Every 2023 key present and correctly typed.
|
|
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
|
|
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
|
|
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
|
|
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
|
|
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
|
|
|
|
var id = it.GetProperty("AvatarItemId").GetInt32();
|
|
Assert.NotEqual(0, id);
|
|
ids.Add(id);
|
|
}
|
|
|
|
// No duplicate ids — a collision is what crashed the wardrobe.
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Purchased_item_appears_owned_with_valid_shape()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
|
|
|
|
// Buy the first avatar-backed watch-storefront item.
|
|
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
|
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
|
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
|
.GetProperty("PurchasableItemId").GetInt32();
|
|
|
|
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
|
new StringContent(JsonSerializer.Serialize(new
|
|
{
|
|
StorefrontType = 3,
|
|
PurchasableItemId = purchasableId,
|
|
CurrencyType = 2,
|
|
RequestedPrice = 1,
|
|
Gift = (object?)null,
|
|
}), Encoding.UTF8, "application/json"));
|
|
buy.EnsureSuccessStatusCode();
|
|
|
|
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
|
|
// Still no duplicate ids after the new item joins the list.
|
|
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
|
Assert.DoesNotContain(0, ids);
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
// Buy an avatar-backed item — the server queues a post-purchase gift box.
|
|
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
|
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
|
|
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
|
|
.GetProperty("PurchasableItemId").GetInt32();
|
|
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
|
|
new StringContent(JsonSerializer.Serialize(new
|
|
{
|
|
StorefrontType = 3, PurchasableItemId = purchasableId,
|
|
CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null,
|
|
}), Encoding.UTF8, "application/json"));
|
|
buy.EnsureSuccessStatusCode();
|
|
|
|
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
|
Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box");
|
|
var gift = gifts[0];
|
|
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
|
|
|
// The gift's desc MUST be the same 4-part "{guid},,," form v4/items
|
|
// emits — the client matches on it verbatim in
|
|
// OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed;
|
|
// a bare guid missed and NRE'd ("items removed on every buy").
|
|
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}");
|
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet();
|
|
Assert.Contains(giftDesc, descs);
|
|
|
|
// Consuming the box must not add a SECOND wardrobe row for the item
|
|
// the direct purchase already granted as a bare guid.
|
|
using var consume = await client.PostAsync(
|
|
$"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}",
|
|
new StringContent("{}", Encoding.UTF8, "application/json"));
|
|
consume.EnsureSuccessStatusCode();
|
|
|
|
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
var baseGuid = giftDesc.Split(',')[0];
|
|
var occurrences = after.EnumerateArray()
|
|
.Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid);
|
|
Assert.Equal(1, occurrences);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
// Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc
|
|
// (no ",,," suffix) — the class of row that made the post-consume
|
|
// UnlockAvatarItemAndMarkNew Find() miss and NRE.
|
|
string bareGuid;
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<Data.DorkNetDbContext>();
|
|
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
|
|
bareGuid = item.Slug["wardrobe-".Length..];
|
|
db.GiftPackages.Add(new Data.Entities.GiftPackageEntity
|
|
{
|
|
RecipientPlayerId = player.PlayerId,
|
|
AvatarItemType = 0, // outfit
|
|
AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug
|
|
IsValid = true,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using var client = ApiClient(player);
|
|
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
|
|
var gift = gifts.EnumerateArray()
|
|
.First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid));
|
|
|
|
// Both desc keys are served as the 4-part form regardless of how the
|
|
// row was stored, so the client's exact-match unlock succeeds. (A
|
|
// real buy also grants the item, so v4/items contains it — covered by
|
|
// Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.)
|
|
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
|
|
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}");
|
|
Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
{
|
|
var settings = scope.ServiceProvider
|
|
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>();
|
|
await settings.SetAllAvatarItemsOwnedEnabledAsync(true);
|
|
}
|
|
|
|
try
|
|
{
|
|
using var client = ApiClient(player);
|
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
|
|
// Variant descs ("{guid},{combo},{mask},") — the colorways — are
|
|
// present, not just base "{guid},,," rows; and every id is unique
|
|
// (base + variants share a base guid, so a base-guid hash would
|
|
// collide → wardrobe dict crash).
|
|
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
|
var hasVariant = descs.Any(d =>
|
|
{
|
|
var p = d.Split(',');
|
|
return p.Length >= 2 && p[1].Length > 0;
|
|
});
|
|
Assert.True(hasVariant, "toggle-on wardrobe has no colored variants");
|
|
|
|
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
|
Assert.DoesNotContain(0, ids);
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
}
|
|
finally
|
|
{
|
|
await using var scope = _factory.Services.CreateAsyncScope();
|
|
await scope.ServiceProvider
|
|
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
|
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
|
|
{
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
|
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
|
try
|
|
{
|
|
using var client = ApiClient(player);
|
|
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
|
|
|
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
|
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
|
|
{
|
|
var drop = row.GetProperty("GiftDrop");
|
|
var desc = drop.GetProperty("AvatarItemDesc").GetString();
|
|
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
|
|
}
|
|
|
|
var matched = 0;
|
|
foreach (var item in owned.EnumerateArray())
|
|
{
|
|
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
|
|
matched++;
|
|
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
|
|
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
|
|
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
|
|
}
|
|
Assert.True(matched > 0, "no owned item matched a shop tile");
|
|
}
|
|
finally
|
|
{
|
|
await using var scope = _factory.Services.CreateAsyncScope();
|
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
|
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
|
|
{
|
|
// The client flags every defaultbaseavataritems row as a custom-
|
|
// clothing template and leaves it out of the owned rebuild, so a
|
|
// normal item listed here showed a price even when owned.
|
|
using var anonymous = ApiClient();
|
|
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
|
|
var byDesc = rows.EnumerateArray().ToDictionary(
|
|
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
|
|
r => r);
|
|
Assert.Equal(2, byDesc.Count);
|
|
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
|
|
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
|
|
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
|
|
|
|
// Templates aren't sold or listed as owned clothing.
|
|
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
|
|
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
|
|
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
|
|
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
|
|
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
|
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
|
try
|
|
{
|
|
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
|
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
|
|
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
|
|
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
|
|
}
|
|
finally
|
|
{
|
|
await using var scope = _factory.Services.CreateAsyncScope();
|
|
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
|
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
|
}
|
|
}
|
|
|
|
// Beard_Close: one of the client's defaultUnlockedVariants.
|
|
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
|
|
|
|
[Fact]
|
|
public async Task Default_unlocked_items_are_owned_by_every_player()
|
|
{
|
|
// The client replaces its unlocked set with each list it downloads,
|
|
// so v4/items must carry the free default items or they show a price.
|
|
using var setup = ApiClient();
|
|
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
|
using var client = ApiClient(player);
|
|
|
|
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
|
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
|
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
|
// A default item the starter inventory also holds appears once.
|
|
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
|
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
|
|
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
|
|
Assert.Equal(0, locked.GetArrayLength());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
|
|
{
|
|
using var anonymous = ApiClient();
|
|
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
|
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
|
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
|
Assert.Equal(
|
|
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
|
|
descs.Count);
|
|
}
|
|
|
|
private HttpClient ApiClient(GameClientSession? session = null)
|
|
{
|
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
|
if (session is not null)
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
|
return client;
|
|
}
|
|
|
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
|
{
|
|
using var response = await client.GetAsync(path);
|
|
response.EnsureSuccessStatusCode();
|
|
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
|
return document.RootElement.Clone();
|
|
}
|
|
}
|