using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Services;
namespace DorkNet.Server.Tests;
///
/// 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.
///
public sealed class PresenceTransitionTests : IClassFixture
{
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 { 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 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 PlayerIdOfAsync(HttpClient client)
{
var beat = await HeartbeatAsync(client);
return beat.GetProperty("playerId").GetInt64();
}
private static async Task 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 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 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(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;
}
}