mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET api/incentivizedreferrals/referrals?take=&continuationToken=</c> is
|
||||
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
|
||||
/// so it never showed up as a route literal and had no handler. The reply is the
|
||||
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
|
||||
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
|
||||
/// </summary>
|
||||
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
|
||||
{
|
||||
using var setup = Client();
|
||||
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
// Same row LinkController.CreditReferralAsync writes on the invitee.
|
||||
foreach (var invitee in new[] { inviteeA, inviteeB })
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = invitee.PlayerId,
|
||||
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = Client(referrer);
|
||||
var first = JsonDocument.Parse(
|
||||
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
|
||||
var firstPage = Get(first, "Referrals");
|
||||
Assert.Equal(1, firstPage.GetArrayLength());
|
||||
var token = Get(first, "ContinuationToken").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
var second = JsonDocument.Parse(await client.GetStringAsync(
|
||||
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
|
||||
var secondPage = Get(second, "Referrals");
|
||||
Assert.Equal(1, secondPage.GetArrayLength());
|
||||
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
|
||||
|
||||
var ids = new[] { firstPage[0], secondPage[0] }
|
||||
.Select(e => Get(e, "InviteeAccountId").GetInt64())
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
|
||||
// CreatedAt is a non-nullable DateTime on the client: it must be present.
|
||||
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
|
||||
}
|
||||
|
||||
// The client formatter registers PascalCase, camelCase and lowercase.
|
||||
private static JsonElement Get(JsonElement e, string pascal) =>
|
||||
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Presence must follow the player through every transition, for the player
|
||||
/// themselves (the heartbeat) and for everyone else (bulk /player and the
|
||||
/// PresenceUpdate push).
|
||||
///
|
||||
/// The heartbeat side is decided by the client's compare
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
|
||||
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
|
||||
/// and anything else makes the client cancel what it is doing and chase the
|
||||
/// server's room. matchmake/none is how the client tells us it went to that
|
||||
/// offline room, so after it the heartbeat must stay null.
|
||||
/// </summary>
|
||||
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// Enter the dorm for real: matchmake, then report the join.
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
var before = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
|
||||
|
||||
// Twice: the first beat must not re-seed a dorm the second one returns.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
var room = RoomInstanceOf(beat);
|
||||
Assert.True(room.ValueKind == JsonValueKind.Null,
|
||||
$"""
|
||||
After matchmake/none the client holds its offline room (instance -1)
|
||||
and is only in sync with a null roomInstance. The heartbeat answered
|
||||
with a room, which makes the client cancel the offline load and
|
||||
chase it:
|
||||
|
||||
{beat.GetRawText()}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
|
||||
// back to the offline room before reporting a result.
|
||||
await MatchmakeDormAsync(client);
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
|
||||
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Matchmaking_again_after_leaving_restores_the_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
using var none = await subject.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.True(row.GetProperty("isOnline").GetBoolean(),
|
||||
$"a player sitting in the offline room is online: {row.GetRawText()}");
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logging_out_shows_the_player_offline_and_roomless()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
|
||||
|
||||
using var logout = await subject.PostAsync("/player/logout", Form());
|
||||
Assert.True(logout.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bulk_presence_reports_the_stored_status_visibility()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
using var put = await subject.PutAsync("/player/statusvisibility",
|
||||
Form(("statusVisibility", "2")));
|
||||
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
|
||||
|
||||
// And the player's own heartbeat agrees.
|
||||
var beat = await HeartbeatAsync(subject);
|
||||
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_push_carries_online_state_independently_of_the_room()
|
||||
{
|
||||
var room = new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 42,
|
||||
RoomId = 7,
|
||||
Name = "DormRoom",
|
||||
PhotonRoomId = "^dormroom_p1",
|
||||
};
|
||||
|
||||
// Online, no room: the offline-room state. Used to be sent as OFFLINE
|
||||
// because isOnline was derived from the room.
|
||||
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
|
||||
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Offline never leaks a room, even if one is still cached.
|
||||
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
|
||||
Assert.False(offline.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Online in a room: redacted join coordinates, visibility passed through,
|
||||
// appVersion a STRING.
|
||||
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
|
||||
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
|
||||
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
|
||||
|
||||
static JsonElement Msg(string payload)
|
||||
{
|
||||
var root = JsonDocument.Parse(payload).RootElement;
|
||||
// String-keyed channel: the client registers "PresenceUpdate".
|
||||
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
|
||||
return root.GetProperty("Msg");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pushes_honour_the_players_status_visibility()
|
||||
{
|
||||
// Live PresenceUpdate frames must hide a player exactly as GET /player
|
||||
// does, or "appear offline" leaks every room change to friends.
|
||||
long[] friends = [10, 11, 12];
|
||||
var favorited = new HashSet<long> { 11 };
|
||||
|
||||
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
|
||||
Assert.Equal(friends, pub.VisibleTo);
|
||||
Assert.Empty(pub.HiddenFrom);
|
||||
|
||||
// Every recipient is already a friend, so FriendsOnly hides nobody.
|
||||
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
|
||||
Assert.Equal(friends, friendsOnly.VisibleTo);
|
||||
Assert.Empty(friendsOnly.HiddenFrom);
|
||||
|
||||
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
|
||||
Assert.Equal([11L], favoritesOnly.VisibleTo);
|
||||
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
|
||||
|
||||
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
|
||||
Assert.Empty(offline.VisibleTo);
|
||||
Assert.Equal(friends, offline.HiddenFrom);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HttpClient> SignedInMatchClientAsync()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<long> PlayerIdOfAsync(HttpClient client)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
return beat.GetProperty("playerId").GetInt64();
|
||||
}
|
||||
|
||||
private static async Task<string> MatchmakeDormAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
|
||||
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
|
||||
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
|
||||
return id!;
|
||||
}
|
||||
|
||||
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
|
||||
{
|
||||
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
|
||||
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
|
||||
{
|
||||
using var resp = await viewer.GetAsync($"/player?{subjectId}");
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
|
||||
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
|
||||
{
|
||||
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
|
||||
}
|
||||
Assert.Fail($"no row for {subjectId}: {body}");
|
||||
return default;
|
||||
}
|
||||
|
||||
private static JsonElement RoomInstanceOf(JsonElement presence)
|
||||
{
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var room),
|
||||
$"no roomInstance key: {presence.GetRawText()}");
|
||||
return room;
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
|
||||
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
|
||||
/// urlencoded field whose name differs from the suffix (displayEmoji,
|
||||
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
|
||||
/// every edit failed with "Failed to modify <suffix>", and no account
|
||||
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
|
||||
/// visibility (PUT player/statusvisibility) was persisted but GET /player
|
||||
/// always reported Public and never hid the player.
|
||||
/// </summary>
|
||||
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
|
||||
|
||||
var me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
|
||||
|
||||
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
|
||||
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
|
||||
|
||||
// Empty clears it.
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
|
||||
me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
// "✌" without U+FE0F resolves to the list's "✌️".
|
||||
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
|
||||
|
||||
void AssertProfile(JsonElement a)
|
||||
{
|
||||
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
|
||||
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
|
||||
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
|
||||
}
|
||||
|
||||
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
|
||||
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
|
||||
|
||||
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
|
||||
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_edits_reject_non_numeric_values()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_account_reports_username_changes_remaining()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
// Absent/0 makes the settings UI say no username changes are left.
|
||||
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bio_edit_round_trips_through_public_bio_lookup()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
|
||||
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
|
||||
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
using var viewerMatch = MatchClient(viewer);
|
||||
|
||||
// Baseline: Public → viewer sees owner online.
|
||||
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.True(before.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
|
||||
|
||||
// Self keeps its own choice (the dialog reads the cached self presence).
|
||||
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
|
||||
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.True(self.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
// Others see an offline player with no room.
|
||||
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.False(other.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
|
||||
{
|
||||
var (owner, friend) = await TwoPlayersAsync();
|
||||
using var setup = AccountsClient();
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Relationships.Add(new RelationshipEntity
|
||||
{
|
||||
RequesterId = owner.PlayerId,
|
||||
TargetId = friend.PlayerId,
|
||||
Status = RelationshipStatus.Friend,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
|
||||
|
||||
using var friendMatch = MatchClient(friend);
|
||||
using var strangerMatch = MatchClient(stranger);
|
||||
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pref_key_matches_status_visibility_writer()
|
||||
{
|
||||
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
|
||||
var field = typeof(MatchPlayerController).GetField(
|
||||
"PrefStatusVisibility",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PutOnline(long playerId)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(playerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 900_000 + playerId,
|
||||
RoomId = 1,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = $"^profiletest{playerId}",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(playerId);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
|
||||
{
|
||||
var list = await GetJsonAsync(client, $"/player?{playerId}");
|
||||
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
|
||||
}
|
||||
|
||||
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
|
||||
{
|
||||
using var setup = AccountsClient();
|
||||
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
|
||||
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
|
||||
|
||||
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
|
||||
{
|
||||
using var response = await PutFormAsync(client, path, field, value);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
|
||||
|
||||
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_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);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
|
||||
using var document = JsonDocument.Parse(body);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
|
||||
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
|
||||
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
|
||||
///
|
||||
/// The failure modes are silent server-side: an empty 200 parses to null and
|
||||
/// null-derefs, so the player sees "Failed to …" after the change was applied;
|
||||
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
|
||||
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
|
||||
/// So the assertion that matters is the body, read the way the client's
|
||||
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
|
||||
/// </summary>
|
||||
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
|
||||
{
|
||||
using var setup = Client("clubs");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var clubId = await SeedOpenClubAsync(owner.PlayerId);
|
||||
|
||||
using var joinerClient = Client("clubs", joiner);
|
||||
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
|
||||
using var join = await joinerClient.PutAsync(
|
||||
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(join, "requesttojoin");
|
||||
|
||||
using var leave = await joinerClient.PostAsync(
|
||||
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(leave, "leave");
|
||||
|
||||
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
|
||||
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
|
||||
|
||||
using var ownerClient = Client("clubs", owner);
|
||||
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
|
||||
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
|
||||
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
|
||||
|
||||
using var accounts = Client("accounts", player);
|
||||
using var privacy = await accounts.PutAsync(
|
||||
"/accountprivacysettings/recenthistoryvisibility",
|
||||
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
|
||||
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_warning_carries_an_error_message()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var response = await client.PostAsync("/api/playerwarnings",
|
||||
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
|
||||
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
// Without Error the client's Promise.Rejected(null) throws instead of
|
||||
// surfacing the refusal.
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
|
||||
{
|
||||
using var setup = Client("auth");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var auth = Client("auth", player);
|
||||
|
||||
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
|
||||
var raw = await mint.Content.ReadAsStringAsync();
|
||||
Assert.True(mint.IsSuccessStatusCode, raw);
|
||||
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
|
||||
var token = JsonSerializer.Deserialize<string>(raw);
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
using var anon = Client("auth");
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "login_token",
|
||||
["account_id"] = player.PlayerId.ToString(),
|
||||
["login_token"] = token!,
|
||||
};
|
||||
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
|
||||
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
|
||||
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
|
||||
|
||||
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
|
||||
{
|
||||
using var anon = Client("auth");
|
||||
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
|
||||
|
||||
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
|
||||
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
|
||||
var deviceCode = started.GetProperty("device_code").GetString()!;
|
||||
var userCode = started.GetProperty("user_code").GetString()!;
|
||||
|
||||
var poll = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||
["device_code"] = deviceCode,
|
||||
};
|
||||
|
||||
// Before approval the client must be told to keep polling: a 400
|
||||
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
|
||||
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
|
||||
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
using var approverClient = Client("auth", approver);
|
||||
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", "NOPE0000")])))
|
||||
{
|
||||
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
|
||||
await AssertRecNetSuccessAsync(approve, "remoteauth");
|
||||
|
||||
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
var grantedBody = await granted.Content.ReadAsStringAsync();
|
||||
Assert.True(granted.IsSuccessStatusCode, grantedBody);
|
||||
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
|
||||
|
||||
// Single use.
|
||||
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
|
||||
{
|
||||
using var client = Client("api");
|
||||
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
|
||||
var row = rows.EnumerateArray().FirstOrDefault(r =>
|
||||
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
|
||||
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
|
||||
|
||||
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
|
||||
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
|
||||
// the entry is logged as "Failed to parse priority mapping".
|
||||
var entries = Prop(row, "Value")!.Split(';');
|
||||
Assert.All(entries, e =>
|
||||
{
|
||||
var parts = e.Split('=');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
|
||||
});
|
||||
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_strings_download_answers_an_empty_table()
|
||||
{
|
||||
using var client = Client("strings");
|
||||
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, body);
|
||||
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
|
||||
}
|
||||
|
||||
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
|
||||
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
|
||||
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
|
||||
[Theory]
|
||||
[InlineData("RoomOwners", 256)]
|
||||
[InlineData("All", int.MaxValue)]
|
||||
[InlineData("None", 0)]
|
||||
[InlineData("256", 256)]
|
||||
[InlineData("roomowners, None", 256)]
|
||||
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
|
||||
Assert.Equal(expected,
|
||||
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
|
||||
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
|
||||
.ParseBroadcastPermissions("SomethingElse"));
|
||||
|
||||
private async Task<long> SeedOpenClubAsync(long ownerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = $"Env{Guid.NewGuid():N}"[..16],
|
||||
CreatorPlayerId = ownerId,
|
||||
Joinability = 0, // Open
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
return club.Id;
|
||||
}
|
||||
|
||||
private async Task<long> SubjectOfAsync(string tokenBody)
|
||||
{
|
||||
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
|
||||
using var client = Client("auth");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
|
||||
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
|
||||
return long.Parse(info.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
|
||||
var root = JsonDocument.Parse(body).RootElement;
|
||||
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
|
||||
}
|
||||
|
||||
// The client formatter registers exactly these spellings.
|
||||
private static bool ReadSuccess(JsonElement root) =>
|
||||
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
|
||||
&& s.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadError(JsonElement root) =>
|
||||
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
|
||||
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
|
||||
: null;
|
||||
|
||||
private static string? Prop(JsonElement row, string name) =>
|
||||
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -523,3 +523,45 @@ POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
|
||||
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
|
||||
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
|
||||
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
|
||||
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
|
||||
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
|
||||
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
|
||||
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
|
||||
# email/phone/confirmphone POST=2).
|
||||
GET api/playerReputation/v2/bulk identity-account
|
||||
POST api/playerReputation/v2/bulk identity-account
|
||||
GET api/players/v2/progression/bulk identity-account
|
||||
POST api/players/v2/progression/bulk identity-account
|
||||
GET strings config-telemetry
|
||||
GET config/{0} config-telemetry
|
||||
GET data/{0} config-telemetry
|
||||
GET sigs/{0} config-telemetry
|
||||
DELETE subscription/{0} commerce-subscription
|
||||
PUT account/me/username identity-account
|
||||
PUT account/me/displayname identity-account
|
||||
PUT account/me/bio identity-account
|
||||
PUT account/me/birthday identity-account
|
||||
POST account/me/email identity-account
|
||||
POST account/me/phone identity-account
|
||||
PUT account/me/emoji identity-account
|
||||
PUT account/me/personalpronouns identity-account
|
||||
PUT account/me/identityflags identity-account
|
||||
PUT account/me/bannerimage identity-account
|
||||
POST account/me/confirmphone identity-account
|
||||
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
|
||||
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
|
||||
GET api/itemWishlists/v1/wishlist/me avatar-inventory
|
||||
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
|
||||
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
|
||||
GET api/incentivizedreferrals/referrals progression-misc
|
||||
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
|
||||
POST account/bulk/email identity-account
|
||||
POST account/bulk/phone identity-account
|
||||
POST api/avatar/v2/gifts/consume avatar-inventory
|
||||
GET video/{0} config-telemetry
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 532 and column 73.
|
Reference in New Issue
Block a user