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