Files
DorkNet/DorkNet.Server.Tests/OutfitMenu2023Tests.cs
Alexa 6ee1d6f9b3 fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.

Presence
- matchmake/none means "online, in no room" (its only caller is
  TryGoToOfflineRoom, which ignores the reply). The server used to keep the
  old room or invent a dorm, so the next heartbeat looked out of sync and
  the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
  it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
  joins, matchmake/none, logout, failed joins, visibility changes, SignalR
  connect/disconnect. isOnline is sent on its own; pushes never go to the
  subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
  is stored and honoured by GET /player and by the live pushes.

Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
  404; added with the client's real form keys and persisted (new Players
  columns plus migration). Every account read now returns them, and edits
  push AccountUpdate to everyone.

Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
  slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
  client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
  form bodies; lockeditems returns the full owned-item shape; colourways no
  longer share their base item's AvatarItemId.

RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
  An empty 200 showed "Failed to ..." after the server applied the change,
  and a body without Success threw "Error message cannot be null". Clubs,
  announcements, keepsakes, comments/read, subscriptions, purchase offers
  and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
  device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
  GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
  Route inventory and coverage test extended to match.

README now describes the 2023 branch.
2026-10-15 14:08:11 +03:00

223 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Avatar.V4;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
/// the locked-item lookup, the owned list's per-entry ids, and the gift
/// generate call the menu's gift flow makes.
/// </summary>
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Creation_allowed_gate_is_a_success_error_envelope()
{
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
// "Failed to query if local account can create Custom Avatar Items".
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
Assert.Equal(JsonValueKind.Object, body.ValueKind);
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
}
[Fact]
public async Task Sibling_gates_and_min_price_are_bare_scalars()
{
using var client = ApiClient();
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
Assert.Equal(JsonValueKind.Number, min.ValueKind);
Assert.True(min.GetInt32() > 0);
}
[Fact]
public async Task Saved_outfit_round_trips_every_client_key()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var shirt = Guid.NewGuid();
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
var outfit = new Dictionary<string, object?>
{
["Slot"] = 3,
["PreviewImageName"] = "preview-3.jpg",
["OutfitSelections"] = "",
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
["FaceFeatures"] = "{\"ver\":3}",
["SkinColor"] = "skin-guid",
["HairColor"] = "hair-guid",
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
};
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
var items = saved.GetProperty("CustomAvatarItems");
Assert.Equal(1, items.GetArrayLength());
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
// Overwriting the slot replaces it rather than adding a second row.
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
outfit["CustomAvatarItems"] = Array.Empty<object>();
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
}
[Fact]
public async Task Gift_generate_accepts_the_clients_form_body()
{
// Live trace: this exact form body was answered 415 under [FromBody].
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["GiftContext"] = "12000",
["IsGameGift"] = "True",
["Message"] = "Activity completed!",
}));
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
}
[Fact]
public async Task Locked_items_carry_the_full_owned_item_shape()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
HashSet<string> owned;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
.Select(a => a.InventoryJson).FirstAsync();
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
var notOwned = AvatarItemsController.Catalog.Keys
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
Assert.Equal(3, notOwned.Count);
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
Assert.Equal(3, rows.GetArrayLength());
var ids = new List<int>();
foreach (var row in rows.EnumerateArray())
{
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
}
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
// Two colorways of one base item that the runtime catalog knows.
var group = StoreService.ColorVariantCatalog.Values
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
var baseGuid = group.Key;
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
inv.Add(baseGuid);
inv.AddRange(variants);
avatar.InventoryJson = JsonSerializer.Serialize(inv);
await db.SaveChangesAsync();
}
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
var sameBase = items.EnumerateArray()
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.Equal(3, sameBase.Count);
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().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 PostJsonAsync(HttpClient client, string path, object body)
{
using var response = await client.PostAsync(path,
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
}
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();
}
}