From 08ab122adc7b074655452cd6a968beab82c9e15c Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:54:28 +0300 Subject: [PATCH] fix: an empty chat thread is not a missing chat thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a chat with a player POSTs thread/withmembers (which creates the thread) and then immediately GETs thread/{id}. At that instant the thread has no messages, and GetThreadById returned NotFound purely on message count — so the client got a 404 for the thread it had just been handed and the conversation could never be opened. Confirmed from a server log: POST /thread/withmembers -> 200 at 06:38:20.862, GET /thread/15 -> 404 at 06:38:21.277. The handler now 404s only when nothing records the thread — no ChatThreads row and no ChatThreadMembers rows — and otherwise returns it with an empty message list. Also adds RoomCloneShapeTests. The 2023 copy-room flow reads the clone reply into FGCPNAACHIK, the same type get-by-id returns (NLDBPDCNNCF.txt:4676 signature, :4802 route literal), so the two responses must stay shape-identical: if they drift, the server still answers 200 and the client throws while reading, surfacing much later from the finalizer as a bare "Failed to copy room" with nothing in the server log. The test compares both top-level and nested sub-room keys, and seeds a real scene row so the comparison exercises the populated branch rather than the synthesised-default fallback that a room with no scenes would take. --- DorkNet.Server.Tests/RoomCloneShapeTests.cs | 119 ++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 DorkNet.Server.Tests/RoomCloneShapeTests.cs diff --git a/DorkNet.Server.Tests/RoomCloneShapeTests.cs b/DorkNet.Server.Tests/RoomCloneShapeTests.cs new file mode 100644 index 0000000..8333d20 --- /dev/null +++ b/DorkNet.Server.Tests/RoomCloneShapeTests.cs @@ -0,0 +1,119 @@ +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; + +/// +/// The 2023 "copy room" flow POSTs rooms/{id}/clone and deserialises the +/// reply into FGCPNAACHIK — the SAME type GET rooms/{id} returns +/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares +/// Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken), +/// with the route literal on :4802). +/// +/// So the two responses must be shape-identical. When they drift, the server +/// still answers 200 and the client throws while reading it — and because the +/// clone promise's exception is never observed, it surfaces from the finalizer +/// thread seconds later as a bare "Failed to copy room: Exception of type +/// '...' was thrown", with nothing in the server log to show for it. That is +/// exactly how this failed in the wild: the room WAS created, and the client +/// simply never registered it. +/// +public sealed class RoomCloneShapeTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Clone_response_matches_the_get_by_id_shape() + { + using var client = Client(); + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + + var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Rooms.Add(new RoomEntity + { + Id = sourceId, + Name = $"CloneSrc{Guid.NewGuid():N}"[..20], + CreatorPlayerId = session.PlayerId, + CloningAllowed = true, + }); + // Give the source a real sub-room. Without one, BOTH responses fall + // back to the synthesised default sub-room and the comparison never + // exercises the branch a real room (like the seeded RecCenter the + // room-creation picker clones) actually takes. + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = sourceId, + Name = "Home", + OrderIndex = 0, + }); + await db.SaveChangesAsync(); + } + + var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}"); + var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", + new FormUrlEncodedContent([new KeyValuePair("name", $"Copy{Guid.NewGuid():N}"[..20])])); + + var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + + var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList(); + + // The reader walks the nested sub-room list too, so a key missing there + // breaks it just as hard as one missing at the top level. + foreach (var list in new[] { "SubRooms", "Scenes" }) + { + if (!getBody.TryGetProperty(list, out var expectedList) || + expectedList.ValueKind != JsonValueKind.Array || + expectedList.GetArrayLength() == 0) continue; + + Assert.True(cloneBody.TryGetProperty(list, out var actualList) + && actualList.ValueKind == JsonValueKind.Array, + $"clone response has no {list} array while get-by-id does"); + Assert.True(actualList.GetArrayLength() > 0, + $"clone response has an EMPTY {list} array while get-by-id returns " + + $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry"); + + var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name); + var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}")); + } + Assert.True(missing.Count == 0, + $""" + The clone response is missing {missing.Count} key(s) that get-by-id + returns. Both deserialize into FGCPNAACHIK, so the client throws while + reading the clone and silently never registers the new room: + + {string.Join(Environment.NewLine, missing)} + + clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))} + """); + } + + private static async Task ReadJsonAsync( + HttpClient client, HttpMethod method, string path, HttpContent? content = null) + { + using var request = new HttpRequestMessage(method, path) { Content = content }; + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, + $"{method} {path} -> {(int)response.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.Clone(); + } + + private HttpClient Client() + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}"); + return client; + } +}