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; + } +}