Files
DorkNet/DorkNet.Server.Tests/ChatStartConversationTests.cs
T
Alexa 9c9f1841d6 feat: cloned rooms get their own copy of the source's save data
Cloning was a shallow copy: the new room's scenes kept pointing at the SOURCE
room's blob until someone saved over them. So every clone of a room shared one
object, a clone read as "already has content" while owning none of it, and the
room a player copied stayed load-bearing for their copy indefinitely. Cloning
RecCenter produced rooms whose data blob was literally room_100_v1.dat, which
is visible in the join log.

Each sub-room's bytes are now copied into the clone's own namespace, keyed by
the CLONE's room id so BlobRouter files them under that room's prefix. Sub-room
0 takes the canonical default name, which matters because the details builder
falls back to exactly that name when a room has no recorded blob — writing the
copy there means both the recorded and the fallback path resolve to real
content. Each copy also gets a RoomDataBlobs row so it is tracked like any
other save.

Two cases deliberately do not clear a reference:

- With no object store configured there is nothing to copy, and a blob name is
  only a reference; clearing it would throw away the player's edits, which is
  strictly worse than the shared reference. That path keeps the old shallow
  behaviour, which is also what the existing user-room clone test asserts.
- A source object that is missing or unreadable leaves the clone empty rather
  than failing the request. The room and its sub-rooms are already committed by
  that point, so a hard failure would strand a half-made room, and an unsaved
  source clones to an empty room anyway.

Everywhere else a reference is either replaced by a real copy or cleared — it
is never left pointing into another room.
2026-07-28 10:28:50 +03:00

89 lines
4.0 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// Starting a chat with a player is a two-step handshake, and both steps have to
/// work on a thread that has never carried a message:
///
/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
/// GET thread/{id} -> opens it
///
/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
/// GET 404'd 415ms later, because the handler decided a thread existed by
/// counting its messages. A brand-new conversation has none, so the client was
/// handed a thread id it was then told did not exist, and no chat could ever be
/// opened with someone you had not already messaged.
/// </summary>
public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
{
using var starterClient = Client();
using var otherClient = Client();
var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
starterClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
// Step 1 — the client asks for the thread with that player. It sends the
// ids as a form body (observed on the wire as
// "ids=9741717&messageCount=50"), which also matters for routing: the
// content type is what separates this from POST thread/{chatThreadId},
// whose parameter segment otherwise also matches "withmembers".
using var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
new KeyValuePair<string, string>("messageCount", "50"),
]);
using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
var createBody = await createResponse.Content.ReadAsStringAsync();
Assert.True(createResponse.IsSuccessStatusCode,
$"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
var thread = JsonDocument.Parse(createBody).RootElement;
Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
|| thread.TryGetProperty("chatThreadId", out idElement),
$"no thread id in the withmembers reply: {createBody}");
var threadId = idElement.ValueKind == JsonValueKind.String
? idElement.GetString()
: idElement.ToString();
Assert.False(string.IsNullOrWhiteSpace(threadId));
// Step 2 — and immediately opens it. No message has been sent yet.
using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
var openBody = await openResponse.Content.ReadAsStringAsync();
Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
$"""
GET /thread/{threadId} returned 404 for a thread that thread/withmembers
had just created. An empty thread is not a missing thread — the chat
cannot be opened until someone has already sent a message, which is
impossible to do first.
""");
Console.WriteLine("=== WITHMEMBERS ===");
Console.WriteLine(createBody);
Console.WriteLine("=== GET THREAD ===");
Console.WriteLine(openBody);
Assert.True(openResponse.IsSuccessStatusCode,
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
}
private HttpClient Client()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
return client;
}
}