using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using RadicadeServer.API; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Notifications; using Xunit; namespace RadicadeServer.Tests; /// /// The chat surface the client actually reads. Three shapes are load-bearing here, and each /// of them was wrong before this suite existed: /// /// * a thread opened as a conversation must NEVER answer an empty `messages` array. The /// client's `RRUI.Data.ChatThreadModel.FetchOlderMessages` pages history from the OLDEST /// message the model already holds, so an empty list throws /// `ArgumentOutOfRangeException: Index was out of range` out of the list build - once per /// frame, from the recycling list's own refresh - and the conversation pane stays blank /// forever. Real threads always open with a `-5` "Player <@U..> started a chat" /// notice as their oldest message, which is why they never hit it. /// * the thread LIST answers `latestMessage` and no `messages` array; the two shapes are how /// the client tells a listed thread from an opened one. A thread with nothing in it is /// still listed, rather than hidden. /// * `GET /chat/thread/{id}/message` is a BARE ARRAY, newest first - the spelling the paging /// client calls - while `GET /chat/thread/{id}` answers an object. /// public class ChatThreadTests { private const long CallerId = 2; private const long FriendId = 3; private const long StrangerId = 999; private const long ThreadId = 1; private static readonly JsonSerializerOptions WireJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private static string Serialize(object? value) => JsonSerializer.Serialize(value, WireJson); private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } private static void SetUser(Controller controller, long accountId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static void SetFormBody(Controller controller, string body) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = "application/x-www-form-urlencoded"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); } private static void SetQuery(Controller controller, string query) => controller.ControllerContext.HttpContext.Request.QueryString = new QueryString(query); /// /// A stored `messages` blob in the format `ChatThread.AddMessage`/`ToJson` writes, which /// is the default serializer's shape - the model's own property names are already /// camelCase, so no naming policy is applied on either side. /// private static string Messages(params (long Id, long Sender, string Contents)[] messages) { return JsonSerializer.Serialize(messages.Select(m => new ChatMessage { chatMessageId = m.Id, chatThreadeId = ThreadId, senderPlayerId = m.Sender, timeSent = new DateTime(2026, 9, 27, 1, 0, 0, DateTimeKind.Utc).AddMinutes(m.Id), contents = m.Contents, }).ToList()); } private static ChatThread Thread(long id, string playerIds, string messages) => new() { chatThreadId = id, chatThreadName = "", lastReadMessageId = 0, playerIds = playerIds, messages = messages, }; private static long[] MessageIds(JsonElement thread) => thread .GetProperty("messages") .EnumerateArray() .Select(m => m.GetProperty("chatMessageId").GetInt64()) .ToArray(); [Fact] public void PartyThreadRoute_UsesTheClientListShape() { var method = typeof(ThreadAPI).GetMethod(nameof(ThreadAPI.GetPartyThread))!; Assert.Contains(method.GetCustomAttributes(), attribute => attribute.Template == "thread/party"); using var db = CreateDb(); var api = new ThreadAPI(db); SetUser(api, CallerId); var result = Assert.IsType(api.GetPartyThread()); using var json = JsonDocument.Parse(Serialize(result.Value)); Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind); Assert.Empty(json.RootElement.EnumerateArray()); } // ---- Opening a conversation ------------------------------------------------- [Fact] public async Task CreateThreadWithMembers_OpensWithTheSystemNotice_SoTheListIsNeverEmpty() { using var db = CreateDb(); var api = new ThreadAPI(db); SetUser(api, CallerId); SetFormBody(api, $"ids={FriendId}&messageCount=50"); var ok = Assert.IsType(await api.CreateThreadWithMembers()); using var doc = JsonDocument.Parse(Serialize(ok.Value)); var thread = doc.RootElement; Assert.Equal(new[] { CallerId, FriendId }, thread.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt64()).ToArray()); // An unnamed thread carries an EMPTY name, never null: the client dereferences this // member unchecked and dies on a DM served null (observed live as // `Failed to GetChatBetweenPlayers` / `JSON parse error: Invalid value`). The // captured real traffic's `chatThreadName: null` is another build's serializer, not // this client's DTO. Assert.Equal("", thread.GetProperty("chatThreadName").GetString()); // And the type is present on every thread: the client deserializes it into a // non-nullable int and drops the whole response when the member is missing. Assert.Equal(0, thread.GetProperty("chatThreadType").GetInt32()); var messages = thread.GetProperty("messages").EnumerateArray().ToArray(); var notice = Assert.Single(messages); Assert.Equal(-5, notice.GetProperty("senderPlayerId").GetInt64()); // The notice's text rides INSIDE the JSON envelope, the only spelling this client's // message model reads text out of: a bare sentence as `contents` is what the 2026-09-30 // 18:14 run reported as `ArgumentException: JSON parse error: Invalid value`. Assert.Equal( """{"Type":0,"Version":1,"Data":"Player <@U3> started a chat"}""", notice.GetProperty("contents").GetString()); // An OPENED thread carries `messages` and never `latestMessage` - the client is sent // one spelling or the other - and the creator has already read the notice. Assert.False(thread.TryGetProperty("latestMessage", out _)); Assert.Equal(notice.GetProperty("chatMessageId").GetInt64(), thread.GetProperty("lastReadMessageId").GetInt64()); Assert.Equal(0, notice.GetProperty("moderationState").GetInt32()); } [Fact] public async Task CreateThreadWithMembers_ReusesTheExistingThread_WithoutASecondNotice() { using var db = CreateDb(); var api = new ThreadAPI(db); SetUser(api, CallerId); SetFormBody(api, $"ids={FriendId}"); var first = Assert.IsType(await api.CreateThreadWithMembers()); using var firstDoc = JsonDocument.Parse(Serialize(first.Value)); var threadId = firstDoc.RootElement.GetProperty("chatThreadId").GetInt64(); SetFormBody(api, $"ids={FriendId}"); var second = Assert.IsType(await api.CreateThreadWithMembers()); using var secondDoc = JsonDocument.Parse(Serialize(second.Value)); Assert.Equal(threadId, secondDoc.RootElement.GetProperty("chatThreadId").GetInt64()); Assert.Single(secondDoc.RootElement.GetProperty("messages").EnumerateArray()); Assert.Equal(1, await db.Chats.CountAsync()); } /// /// The ORDER, the presence and the nullability of these members are part of the wire /// contract, not style, and the client says so loudly when they drift: `Malformed /// Response: '[ { "id": 5, "Id": 5, "chatThreadId": 5, ...'` in the 2026-09-30 log is /// what the leading `id`/`Id`/`name` alias members did to the thread list, and /// `Failed to GetChatBetweenPlayers` with `JSON parse error: Invalid value` is what a /// null `chatThreadName` and a missing `chatThreadType` did to an opened thread. These /// are the working reference's orders (`.external/recflare-server/apps/chat`, whose /// comments record each of those client throws), so a change to any of them has to be a /// deliberate one. /// private static string[] FieldOrder(JsonElement element) => element .EnumerateObject() .Select(property => property.Name) .ToArray(); [Fact] public async Task WireShapes_BindPositionally_InTheReferenceOrder() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages( (1, -5, """{"Type":0,"Version":1,"Data":"Player <@U3> started a chat"}"""), (2, FriendId, """{"Type":0,"Version":1,"Data":"hi"}""")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); // The thread list item: latestMessage leads, then the thread's own fields. var list = Assert.IsType(await api.GetThread(50, QueryMode.Latest)); using (var doc = JsonDocument.Parse(Serialize(list.Value))) { var item = doc.RootElement.EnumerateArray().Single(); Assert.Equal( new[] { "latestMessage", "chatThreadId", "playerIds", "lastReadMessageId", "chatThreadName", "chatThreadType", "snoozedUntil", "isFavorited" }, FieldOrder(item)); // A listed thread carries the newest message ONLY - never a `messages` array. Assert.False(item.TryGetProperty("messages", out _)); Assert.Equal(2, item.GetProperty("latestMessage").GetProperty("chatMessageId").GetInt64()); // The named DM: an empty string, not a null the client would dereference. Assert.Equal("", item.GetProperty("chatThreadName").GetString()); Assert.Equal(0, item.GetProperty("chatThreadType").GetInt32()); } // An opened thread: the thread's own fields, then the page of messages - and no // `latestMessage`, which is the member `messages` replaces. var opened = Assert.IsType(await api.GetThreadById(ThreadId)); using (var doc = JsonDocument.Parse(Serialize(opened.Value))) { var thread = doc.RootElement; Assert.Equal( new[] { "chatThreadId", "playerIds", "lastReadMessageId", "chatThreadName", "chatThreadType", "snoozedUntil", "isFavorited", "messages" }, FieldOrder(thread)); Assert.False(thread.TryGetProperty("latestMessage", out _)); Assert.Equal("", thread.GetProperty("chatThreadName").GetString()); Assert.Equal(0, thread.GetProperty("chatThreadType").GetInt32()); var message = thread.GetProperty("messages").EnumerateArray().First(); Assert.Equal( new[] { "chatMessageId", "chatThreadId", "senderPlayerId", "timeSent", "contents", "moderationState" }, FieldOrder(message)); } } [Fact] public async Task OpenThread_PagesNewestFirst_WithoutTheListSpelling() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages( (1, -5, """{"Type":0,"Version":1,"Data":"Player <@U3> started a chat"}"""), (2, FriendId, """{"Type":0,"Version":1,"Data":"hi"}"""), (3, CallerId, """{"Type":0,"Version":1,"Data":"yo"}""")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); SetQuery(api, "?messageCount=2"); var ok = Assert.IsType(await api.GetThreadById(ThreadId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); var thread = doc.RootElement; // Newest first, and only the page the caller asked for: the newest message leads the // page, and there is no `latestMessage` alongside it. Assert.Equal(new long[] { 3, 2 }, MessageIds(thread)); Assert.False(thread.TryGetProperty("latestMessage", out _)); // The per-viewer fields the client renders the header and the star from. Assert.Equal(JsonValueKind.Null, thread.GetProperty("snoozedUntil").ValueKind); Assert.False(thread.GetProperty("isFavorited").GetBoolean()); Assert.Equal("", thread.GetProperty("chatThreadName").GetString()); var newest = thread.GetProperty("messages").EnumerateArray().First(); Assert.Equal("""{"Type":0,"Version":1,"Data":"yo"}""", newest.GetProperty("contents").GetString()); Assert.Equal(CallerId, newest.GetProperty("senderPlayerId").GetInt64()); } [Fact] public async Task OpenThread_WithNoMessages_StillOpens() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", "[]")); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); var ok = Assert.IsType(await api.GetThreadById(ThreadId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(JsonValueKind.Array, doc.RootElement.GetProperty("messages").ValueKind); Assert.Empty(doc.RootElement.GetProperty("messages").EnumerateArray()); } // ---- Paging history --------------------------------------------------------- [Fact] public async Task ThreadMessages_IsABareArray_NewestFirst_AndPagesBackwards() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages( (1, -5, "notice"), (2, FriendId, "hi"), (3, CallerId, "yo")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); // The newest page - the spelling ChatThreadModel.FetchOlderMessages opens with. SetQuery(api, "?MessageCount=2&Mode=0"); var newest = Assert.IsType(await api.GetThreadMessages(ThreadId)); using (var doc = JsonDocument.Parse(Serialize(newest.Value))) { Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(new long[] { 3, 2 }, doc.RootElement.EnumerateArray().Select(m => m.GetProperty("chatMessageId").GetInt64()).ToArray()); } // Walking back from an anchor message. SetQuery(api, "?MessageCount=2&Mode=2&MessageId=3"); var older = Assert.IsType(await api.GetThreadMessages(ThreadId)); using (var doc = JsonDocument.Parse(Serialize(older.Value))) { Assert.Equal(new long[] { 2, 1 }, doc.RootElement.EnumerateArray().Select(m => m.GetProperty("chatMessageId").GetInt64()).ToArray()); } // forward from an anchor, newest first SetQuery(api, "?MessageCount=10&Mode=1&MessageId=1"); var newer = Assert.IsType(await api.GetThreadMessages(ThreadId)); using (var doc = JsonDocument.Parse(Serialize(newer.Value))) { Assert.Equal(new long[] { 3, 2 }, doc.RootElement.EnumerateArray().Select(m => m.GetProperty("chatMessageId").GetInt64()).ToArray()); } } [Fact] public async Task ThreadMessages_AnchoredOnReferenceMessageId_PagesOlderThanTheAnchor() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages( (1, -5, "notice"), (2, FriendId, "hi"), (3, CallerId, "yo")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); // The exact call the client's FetchOlderMessages makes. The anchor is the OLDEST // message it holds, so nothing is older than it and the page is empty - answering // the newest messages instead (which is what ignoring `referenceMessageId` did) makes // the client re-request forever. SetQuery(api, "?messageCount=8&mode=2&referenceMessageId=1"); var empty = Assert.IsType(await api.GetThreadMessages(ThreadId)); using (var doc = JsonDocument.Parse(Serialize(empty.Value))) Assert.Empty(doc.RootElement.EnumerateArray()); // Anchored higher up it walks backwards from that message, newest first. SetQuery(api, "?messageCount=8&mode=2&referenceMessageId=3"); var older = Assert.IsType(await api.GetThreadMessages(ThreadId)); using (var doc = JsonDocument.Parse(Serialize(older.Value))) Assert.Equal(new long[] { 2, 1 }, doc.RootElement.EnumerateArray().Select(m => m.GetProperty("chatMessageId").GetInt64()).ToArray()); } [Fact] public async Task ReadMessage_AnswersTheBareChatResultNumber_AndMovesTheReadPointer() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages((1, -5, "notice"), (2, FriendId, "hi")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); var ok = Assert.IsType(await api.ReadThreadMessage(ThreadId, 2)); // A BARE number: the client deserializes this body straight into its ChatResult enum, // so the `{ chatResult: 0 }` envelope fails as "Failed to ReadMessage". Assert.Equal(0, ok.Value); Assert.Equal(2, (await db.Chats.FirstAsync(x => x.chatThreadId == ThreadId)).lastReadMessageId); // Reading backwards never moves the pointer. Assert.Equal(0, Assert.IsType(await api.ReadThreadMessage(ThreadId, 1)).Value); Assert.Equal(2, (await db.Chats.FirstAsync(x => x.chatThreadId == ThreadId)).lastReadMessageId); // Still a 200 with a result code, not a 404, for a non-member or a missing thread. SetUser(api, StrangerId); Assert.Equal(3, Assert.IsType(await api.ReadThreadMessage(ThreadId, 2)).Value); Assert.Equal(2, Assert.IsType(await api.ReadThreadMessage(404, 2)).Value); } [Fact] public async Task ThreadMessages_WithAnUnparseablePageSize_FallsBackRatherThan400ing() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages((1, -5, "notice"), (2, FriendId, "hi")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); SetQuery(api, "?MessageCount=lots&Mode=whatever"); var ok = Assert.IsType(await api.GetThreadMessages(ThreadId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(2, doc.RootElement.GetArrayLength()); } // ---- Sending --------------------------------------------------------------- [Fact] public async Task JsonWithoutContentLength_CreatesConversationAndPersistsMessagesAcrossRequests() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; long threadId; using (var db = new RecDBContext(options)) { var api = new ThreadAPI(db); SetUser(api, CallerId); api.Request.ContentType = "application/json"; api.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"playerIds\":[3],\"messageContents\":\"hello\"}")); Assert.Null(api.Request.ContentLength); var result = Assert.IsType(await api.CreateThread()); using var json = JsonDocument.Parse(Serialize(result.Value)); Assert.Equal(0, json.RootElement.GetProperty("chatResult").GetInt32()); threadId = json.RootElement.GetProperty("chatThread").GetProperty("chatThreadId").GetInt64(); } using (var db = new RecDBContext(options)) { var api = new ThreadAPI(db); SetUser(api, FriendId); var result = Assert.IsType(await api.GetThreadById(threadId)); using var json = JsonDocument.Parse(Serialize(result.Value)); // Posted as bare text (the real client posts the envelope; this test posts a plain // sentence), and served wrapped: `contents` leaves here as the JSON envelope the // client's message model parses, whether the row stored one or not. Assert.Equal( """{"Type":0,"Version":1,"Data":"hello"}""", json.RootElement.GetProperty("messages").EnumerateArray().First().GetProperty("contents").GetString()); api.Request.ContentType = "application/json"; api.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"messageContents\":\"reply\"}")); await api.SendMessageToThread(threadId); } using (var db = new RecDBContext(options)) { var messages = (await db.Chats.SingleAsync()).GetMessages()!; Assert.Equal(new[] { "hello", "reply" }, messages.Where(x => x.senderPlayerId > 0).Select(x => x.contents)); } } [Fact] public void AddMessage_PreservesTheEntireStoredHistory() { var thread = Thread(ThreadId, "[2,3]", Messages((1, CallerId, "first"))); thread.messages = thread.AddMessage(new ChatMessage { chatMessageId = 2, contents = "second" }); Assert.Equal(new[] { "first", "second" }, thread.GetMessages()!.Select(x => x.contents)); } [Fact] public async Task LegacySingleMessageHistory_CanBeOpenedAndListed() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, "[2,3]", JsonSerializer.Serialize(new ChatMessage { chatMessageId = 1, chatThreadeId = ThreadId, senderPlayerId = CallerId, contents = "saved message", timeSent = DateTime.UtcNow }))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, FriendId); Assert.IsType(await api.GetThread(50, QueryMode.Latest)); var result = Assert.IsType(await api.GetThreadById(ThreadId)); using var json = JsonDocument.Parse(Serialize(result.Value)); // A row written before the envelope spelling (bare text) is wrapped on the way out, so // the client reads its text instead of rejecting the whole thread. Assert.Equal( """{"Type":0,"Version":1,"Data":"saved message"}""", json.RootElement.GetProperty("messages").EnumerateArray().First().GetProperty("contents").GetString()); } [Fact] public async Task SendMessage_AnswersTheWholeThread_AndCountsAsReading() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages((1, -5, "notice")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); var envelope = """{"Type":0,"Version":2,"Data":"@test"}"""; SetFormBody(api, "messageContents=" + Uri.EscapeDataString(envelope)); var ok = Assert.IsType(await api.SendMessageToThread(ThreadId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); var root = doc.RootElement; Assert.Equal(0, root.GetProperty("chatResult").GetInt32()); var thread = root.GetProperty("chatThread"); Assert.Equal(new long[] { 2, 1 }, MessageIds(thread)); Assert.Equal(envelope, thread.GetProperty("messages").EnumerateArray().First().GetProperty("contents").GetString()); // Sending is reading: the sender's own pointer is already at the message just posted. Assert.Equal(2, thread.GetProperty("lastReadMessageId").GetInt64()); var stored = await db.Chats.FirstAsync(x => x.chatThreadId == ThreadId); Assert.Equal(2, stored.GetMessages()!.Max(m => m.chatMessageId)); } [Fact] public async Task SendMessage_PushesTheChatNotificationUnderItsRegisteredName_NotTheEnumNumber() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages((1, -5, "notice")))); await db.SaveChangesAsync(); var recipient = new RecordingSingleClient(); var hubClients = DispatchProxy.Create(); ((HubClientsProxy)(object)hubClients).Single = recipient; var hubContext = DispatchProxy.Create, HubContextProxy>(); ((HubContextProxy)(object)hubContext).HubClients = hubClients; var previousContext = Notify.Context; var previousClients = Notify.Clients; try { Notify.Context = hubContext; Notify.Clients = new List { new() { AccountId = FriendId, ConnectionId = "chat-test-recipient", Subscriptions = new List() }, }; var api = new ThreadAPI(db); SetUser(api, CallerId); SetFormBody(api, "messageContents=" + Uri.EscapeDataString("""{"Type":0,"Version":2,"Data":"hello"}""")); Assert.IsType(await api.SendMessageToThread(ThreadId)); var pushed = Assert.Single(recipient.Sent); // The client registers its chat handler under the literal NAME and has NO handler // for the number - verified by hooking its dispatcher registrations, which print // every accepted key at login. An id it has no handler for is dropped silently, so // a numeric id here means the recipient's conversation never updates. Assert.Contains("\"Id\":\"ChatMessageReceived\"", pushed); Assert.DoesNotContain("\"Id\":\"90\"", pushed); Assert.Contains("hello", pushed); } finally { Notify.Context = previousContext; Notify.Clients = previousClients; } } public class HubContextProxy : DispatchProxy { public IHubClients HubClients = null!; protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name == "get_Clients" ? HubClients : throw new NotSupportedException(); } public class HubClientsProxy : DispatchProxy { public ISingleClientProxy Single = null!; protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name == "Client" ? Single : throw new NotSupportedException(); } private sealed class RecordingSingleClient : ISingleClientProxy { public List Sent { get; } = new(); public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Sent.Add(Assert.IsType(args[0])); return Task.CompletedTask; } } [Fact] public async Task BlankMessageContents_ReportsInvalidArguments_WithTheThreadAttached() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages((1, -5, "notice")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, CallerId); SetFormBody(api, "messageContents="); // The client posts exactly this right after /thread/withmembers to "open" the thread, // and the real server answers 200 + invalid-arguments rather than an error status. var ok = Assert.IsType(await api.SendMessageToThread(ThreadId)); using var doc = JsonDocument.Parse(Serialize(ok.Value)); Assert.Equal(1, doc.RootElement.GetProperty("chatResult").GetInt32()); Assert.Single(doc.RootElement.GetProperty("chatThread").GetProperty("messages").EnumerateArray()); SetFormBody(api, "messageContents="); var create = Assert.IsType(await api.CreateThread()); using var createDoc = JsonDocument.Parse(Serialize(create.Value)); Assert.Equal(1, createDoc.RootElement.GetProperty("chatResult").GetInt32()); } // ---- Membership ------------------------------------------------------------- [Fact] public async Task NonMembers_GetNotFoundFromEveryThreadRoute() { using var db = CreateDb(); db.Chats.Add(Thread(ThreadId, $"[{CallerId},{FriendId}]", Messages((1, -5, "notice")))); await db.SaveChangesAsync(); var api = new ThreadAPI(db); SetUser(api, StrangerId); Assert.IsType(await api.GetThreadById(ThreadId)); Assert.IsType(await api.GetThreadMessages(ThreadId)); SetFormBody(api, "messageContents=" + Uri.EscapeDataString("""{"Type":0,"Version":1,"Data":"hi"}""")); Assert.IsType(await api.SendMessageToThread(ThreadId)); } }