diff --git a/DorkNet.Server.Tests/ChatStartConversationTests.cs b/DorkNet.Server.Tests/ChatStartConversationTests.cs
new file mode 100644
index 0000000..f67e8ab
--- /dev/null
+++ b/DorkNet.Server.Tests/ChatStartConversationTests.cs
@@ -0,0 +1,83 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// 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.
+///
+public sealed class ChatStartConversationTests : IClassFixture
+{
+ 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("ids", other.PlayerId.ToString()),
+ new KeyValuePair("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.
+ """);
+ 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;
+ }
+}