using System.Globalization; using System.Text.Json; using RadicadeServer.API.Notifications; using RadicadeServer.Clubs; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Notifications; using Xunit; namespace RadicadeServer.Tests; public class WatchWireCompatibilityTests { // Notifs and NotificationType carry the same values as the client's own enum // (RelationshipChanged = 1, MessageReceived = 2), so both must reach the wire the same way. [Theory] [InlineData(Notifs.RelationshipChanged, "1")] [InlineData(Notifs.MessageReceived, "2")] public void FriendNotificationUsesNumericStringIdWithNumericPayload(Notifs eventType, string expectedId) { var wire = new Notification { Id = eventType, Msg = new { Type = MessageType.FriendInvite, AccountId = 2 } }.ToJson(); using var json = JsonDocument.Parse(wire); Assert.Equal(expectedId, json.RootElement.GetProperty("Id").GetString()); Assert.Equal(4, json.RootElement.GetProperty("Msg").GetProperty("Type").GetInt32()); Assert.Equal(2, json.RootElement.GetProperty("Msg").GetProperty("AccountId").GetInt32()); } // The 2023 client requires a STRING at the notification Id field AND dispatches on the // number inside it. A quoted number is the only form that satisfies both: a bare number // throws in the client's deserializer ("expected:'String Begin Token', actual:'1'"), and // the enum's name parses but matches no registered handler, so the frame is ignored. [Theory] [InlineData(NotificationType.GiftPackageReceivedImmediate, 31)] [InlineData(NotificationType.SubscriptionUpdatePresence, 12)] [InlineData(NotificationType.ModerationKick, 22)] [InlineData(NotificationType.MessageReceived, 2)] [InlineData(NotificationType.RefreshLogin, 5)] public void NotificationIdIsTheClientEnumNumberAsAString(NotificationType eventType, int expectedId) { using var json = JsonDocument.Parse(new Notification { Id = eventType, Msg = new { AccountId = 2 } }.ToJson()); var id = json.RootElement.GetProperty("Id"); Assert.Equal(JsonValueKind.String, id.ValueKind); Assert.Equal(expectedId.ToString(CultureInfo.InvariantCulture), id.GetString()); Assert.Equal((int)eventType, expectedId); Assert.Equal(2, json.RootElement.GetProperty("Msg").GetProperty("AccountId").GetInt32()); } // Both halves of the contract, stated as the client would experience them: the name is // never what goes out, and the number is never sent bare. [Theory] [InlineData(NotificationType.MessageReceived, "2", "MessageReceived")] [InlineData(NotificationType.ModerationKick, "22", "ModerationKick")] public void NotificationIdIsNeitherTheNameNorABareNumber(NotificationType eventType, string expectedNumber, string name) { var notification = new Notification { Id = eventType, Msg = new { } }; var wire = notification.ToJson(); using var json = JsonDocument.Parse(wire); Assert.NotEqual(JsonValueKind.Number, json.RootElement.GetProperty("Id").ValueKind); // Through the property, exactly as the push log does it: the log has to show the token // the client will parse, not what a boxed enum's ToString() happens to be. Assert.Equal($"\"{expectedNumber}\"", Notify.WireId(notification.Id)); Assert.DoesNotContain($"\"{name}\"", wire); } // An explicit string id set by a caller is passed through untouched. [Fact] public void ExplicitStringNotificationIdIsUnchanged() { using var json = JsonDocument.Parse(new Notification { Id = "ReputationUpdate", Msg = new { } }.ToJson()); Assert.Equal("ReputationUpdate", json.RootElement.GetProperty("Id").GetString()); } // These ids are NOT members of the client's enum, so they are named events and must keep // travelling as strings (the reference server sends exactly these spellings). [Theory] [InlineData("PresenceUpdate")] [InlineData("AccountUpdate")] [InlineData("SelfAccountUpdate")] [InlineData("ReputationUpdate")] public void NonEnumNotificationIdStaysAString(string eventName) { using var json = JsonDocument.Parse(new Notification { Id = eventName, Msg = new { AccountId = 2 } }.ToJson()); Assert.Equal(JsonValueKind.String, json.RootElement.GetProperty("Id").ValueKind); Assert.Equal(eventName, json.RootElement.GetProperty("Id").GetString()); } // The push log prints the id with the same token the client will parse, so the server log // shows the frame's real shape. A boxed enum prints its NAME through ToString() no matter // how the frame was serialized, which is exactly why a log line reading "ModerationKick" // looked healthy while every frame went out unparseable. [Theory] [InlineData(NotificationType.ModerationKick, "22")] [InlineData(NotificationType.MessageReceived, "2")] [InlineData(NotificationType.RefreshLogin, "5")] public void PushLogShowsTheWireId(NotificationType eventType, string expected) => Assert.Equal(expected, Notify.WireId(eventType)); [Theory] [InlineData("PresenceUpdate")] [InlineData("AccountUpdate")] [InlineData("SelfAccountUpdate")] public void PushLogQuotesANamedId(string eventName) => Assert.Equal($"\"{eventName}\"", Notify.WireId(eventName)); // The diagnostic A/B (/diag/notify) has to be able to produce all three forms, because the // point of it is to show the live client accepting one and rejecting the other two. [Fact] public void IdFormJsonProducesTheCanonicalQuotedNumberByDefault() { Assert.Equal("\"22\"", Notify.IdFormJson(null, NotificationType.ModerationKick)); Assert.Equal("\"22\"", Notify.IdFormJson("string", NotificationType.ModerationKick)); } [Theory] [InlineData("number", "22")] [InlineData("name", "\"ModerationKick\"")] public void IdFormJsonCanProduceTheFormsTheClientRejects(string form, string expected) => Assert.Equal(expected, Notify.IdFormJson(form, NotificationType.ModerationKick)); // The A/B envelope keeps the real Msg bytes and only swaps the Id token, so what the client // is reacting to is genuinely just the id's form. [Fact] public void DiagnosticEnvelopeSwapsOnlyTheIdToken() { var message = new Notification { Id = NotificationType.MessageReceived, Msg = new { Type = MessageType.CoachMessage, Data = "hello" } }; using var json = JsonDocument.Parse(Notify.EnvelopeWithIdForm(message, "\"2\"")); Assert.Equal("2", json.RootElement.GetProperty("Id").GetString()); Assert.Equal(100, json.RootElement.GetProperty("Msg").GetProperty("Type").GetInt32()); Assert.Equal("hello", json.RootElement.GetProperty("Msg").GetProperty("Data").GetString()); } [Theory] [InlineData(2, 100)] [InlineData(3, 10)] [InlineData(0, 0)] public void ClubMembershipUsesClientEnumValues(long viewerId, int expected) { var club = new ClubRecord { clubId = 1, ownerPlayerId = 2, members = viewerId == 3 ? [new ClubMemberRecord { accountId = viewerId, membershipType = ClubMembershipTypes.Member }] : [] }; using var details = JsonDocument.Parse(JsonSerializer.Serialize(ClubStore.BuildClubDetails(club, viewerId))); using var membership = JsonDocument.Parse(JsonSerializer.Serialize(ClubStore.BuildMembership(club, viewerId))); Assert.Equal(expected, details.RootElement.GetProperty("MyMembershipType").GetInt32()); Assert.Equal(expected, membership.RootElement.GetProperty("membershipType").GetInt32()); } }