using System.Net.Http.Headers; using System.Text.Json; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace DorkNet.Server.Tests; /// /// While a join is pending, the heartbeat must tell the joining player which /// instance they are joining — never null. /// /// Deferring the presence commit is about what everyone ELSE sees: presence /// stays uncommitted until the join is confirmed so a failed join leaves no /// ghost behind. It must not change what the joining player is told about /// themselves, because the client XORs (cachedRoomInstance != null) against /// (serverRoomInstance != null): holding the target while the server says null /// reads as the room vanishing mid-join. The client then logs "presence /// heartbeat response indicates local presence is out-of-sync", "Attempting to /// go to Invalid RoomInstance", and cancels the very join the server was /// waiting on — so it retries until a heartbeat happens to miss the window, and /// entering a dorm takes several attempts while looking like a load failure. /// public sealed class PendingJoinPresenceTests : IClassFixture { private readonly DorkNetServerFactory _factory; public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory; [Fact] public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined() { using var client = Client("match"); var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); // matchmake/dorm is what arms the deferred-commit pending join. using var dormForm = new FormUrlEncodedContent( [new KeyValuePair("MaxPersistenceVersion", "45")]); using var dorm = await client.PostAsync("/matchmake/dorm", dormForm); var dormBody = await dorm.Content.ReadAsStringAsync(); Assert.True(dorm.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}"); var expectedInstance = JsonDocument.Parse(dormBody).RootElement; var instanceId = FindInstanceId(expectedInstance); Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}"); // The join has NOT been reported yet, so the commit is still deferred — // this is exactly the window the client heartbeats in. using var beatForm = new FormUrlEncodedContent( [new KeyValuePair("LoginLock", Guid.NewGuid().ToString())]); using var beat = await client.PostAsync("/player/heartbeat", beatForm); var beatBody = await beat.Content.ReadAsStringAsync(); Assert.True(beat.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}"); var presence = JsonDocument.Parse(beatBody).RootElement; Assert.True(presence.TryGetProperty("roomInstance", out var reported) || presence.TryGetProperty("RoomInstance", out reported), $"no roomInstance key in the heartbeat: {beatBody}"); Assert.True(reported.ValueKind != JsonValueKind.Null, $""" The heartbeat returned a null roomInstance while the player's join was still pending. The client reads that as the room vanishing mid-join and cancels the load it is waiting on: {beatBody} """); Assert.Equal(instanceId, FindInstanceId(reported)); } /// Pull roomInstanceId out of either the matchmake reply (which /// wraps it) or the heartbeat's roomInstance object, whatever the casing. 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; } private HttpClient Client(string host) { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}"); return client; } }