mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Microsoft.AspNetCore.Mvc.HttpGetAttribute>(),
|
||||
attribute => attribute.Template == "thread/party");
|
||||
|
||||
using var db = CreateDb();
|
||||
var api = new ThreadAPI(db);
|
||||
SetUser(api, CallerId);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(await api.ReadThreadMessage(ThreadId, 2)).Value);
|
||||
Assert.Equal(2, Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(await api.GetThread(50, QueryMode.Latest));
|
||||
var result = Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<IHubClients, HubClientsProxy>();
|
||||
((HubClientsProxy)(object)hubClients).Single = recipient;
|
||||
var hubContext = DispatchProxy.Create<IHubContext<NotifyHub>, HubContextProxy>();
|
||||
((HubContextProxy)(object)hubContext).HubClients = hubClients;
|
||||
|
||||
var previousContext = Notify.Context;
|
||||
var previousClients = Notify.Clients;
|
||||
try
|
||||
{
|
||||
Notify.Context = hubContext;
|
||||
Notify.Clients = new List<HubClient>
|
||||
{
|
||||
new() { AccountId = FriendId, ConnectionId = "chat-test-recipient", Subscriptions = new List<long>() },
|
||||
};
|
||||
|
||||
var api = new ThreadAPI(db);
|
||||
SetUser(api, CallerId);
|
||||
SetFormBody(api, "messageContents=" + Uri.EscapeDataString("""{"Type":0,"Version":2,"Data":"hello"}"""));
|
||||
|
||||
Assert.IsType<OkObjectResult>(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<string> Sent { get; } = new();
|
||||
|
||||
public Task<T> InvokeCoreAsync<T>(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Sent.Add(Assert.IsType<string>(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<OkObjectResult>(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<OkObjectResult>(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<NotFoundResult>(await api.GetThreadById(ThreadId));
|
||||
Assert.IsType<NotFoundResult>(await api.GetThreadMessages(ThreadId));
|
||||
|
||||
SetFormBody(api, "messageContents=" + Uri.EscapeDataString("""{"Type":0,"Version":1,"Data":"hi"}"""));
|
||||
Assert.IsType<NotFoundResult>(await api.SendMessageToThread(ThreadId));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user