using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using RadicadeServer.API.Moderation; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Moderation; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The three things this server can do to an account, and the rules that keep them apart: /// /// * a timeout is never appealable - no code is issued, so the client draws neither the appeal /// button nor the support text under it; /// * a voice moderation ban keeps the appeal button and refuses the appeal behind it; /// * a voice restriction is not a ban at all: it cuts the microphone and leaves the account /// status screen, the ban list and the reputation alone. /// public class ModerationTimeoutVoiceTests { private const long Player = 42; private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static async Task WithProfile(long accountId = Player, string? role = null) { var db = NewDb(); db.Profiles.Add(new Profile { accountId = accountId, username = $"player{accountId}", displayName = $"Player {accountId}", profileImage = "", role = role ?? "" }); await db.SaveChangesAsync(); return db; } private static DefaultHttpContext SignedInAs(long accountId) => new() { User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity( new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, accountId.ToString()) }, "test")) }; private static ModerationDB TimeoutBan(long playerId, string method, DateTime? expiry = null) => new() { PlayerId = playerId, IsBan = true, Message = "You have received a timeout due to a high number of reports for Trolling.", Category = ReportCategory.CoC_Trolling, BanMethod = method, CreatedAt = DateTime.UtcNow, ExpireDuration = expiry ?? DateTime.UtcNow.AddHours(1) }; // ---------------------------------------------------------------- policy [Fact] public void ATimeoutIsBetweenAnHourAndADay() { Assert.Equal(TimeSpan.FromHours(1), ModerationPolicy.ClampTimeout(TimeSpan.FromMinutes(1))); Assert.Equal(TimeSpan.FromHours(24), ModerationPolicy.ClampTimeout(TimeSpan.FromDays(7))); Assert.Equal(TimeSpan.FromHours(6), ModerationPolicy.ClampTimeout(TimeSpan.FromHours(6))); } [Fact] public void TimeoutsAreNotAppealableAndThatIsWhatSeparatesThemFromABan() { Assert.False(ModerationPolicy.IssuesAppealCode("Automatic Timeout")); Assert.False(ModerationPolicy.IssuesAppealCode("Moderator Report")); Assert.False(ModerationPolicy.IssuesAppealCode("Timeout")); Assert.True(ModerationPolicy.IssuesAppealCode("Admin Panel")); Assert.True(ModerationPolicy.IssuesAppealCode("Voice Moderation")); } [Fact] public void AVoiceBanDrawsTheButtonAndThenRefusesTheAppeal() { Assert.True(ModerationPolicy.IssuesAppealCode("Voice Moderation")); Assert.True(ModerationPolicy.RefusesAppeal("Voice Moderation")); Assert.False(ModerationPolicy.RefusesAppeal("Admin Panel")); Assert.False(ModerationPolicy.IssuesAppealCode("Voice Moderation") && !ModerationPolicy.RefusesAppeal("Voice Moderation"), "a voice ban must never be a ban that can actually be appealed"); } [Fact] public void ATimeoutStartsAtAnHourUnlessGivenADuration() { var now = new DateTime(2026, 10, 12, 12, 0, 0, DateTimeKind.Utc); Assert.Equal(now.AddHours(1), ModerationPolicy.TimeoutExpiry(now)); Assert.Equal(now.AddHours(8), ModerationPolicy.TimeoutExpiry(now, TimeSpan.FromHours(8))); Assert.Equal(now.AddHours(24), ModerationPolicy.TimeoutExpiry(now, TimeSpan.FromDays(30))); } [Fact] public void ExtendingATimeoutMeasuresFromWhenItStartedAndStopsAtADay() { var started = new DateTime(2026, 10, 12, 12, 0, 0, DateTimeKind.Utc); var current = started.AddHours(1); // More reports raise the duration but do not restart the clock. Assert.Equal(started.AddHours(6), ModerationPolicy.ExtendedTimeoutExpiry(started, current, TimeSpan.FromHours(6))); // A shorter ask never shortens a timeout that is already running. Assert.Equal(current, ModerationPolicy.ExtendedTimeoutExpiry(started, current, TimeSpan.FromMinutes(5))); // And the whole thing stops at a day from when it started. Assert.Equal(started.AddHours(24), ModerationPolicy.ExtendedTimeoutExpiry(started, current, TimeSpan.FromDays(2))); } // ---------------------------------------------------------------- timeouts [Fact] public async Task ATimeoutCannotBeAppealedAndCarriesNoCode() { await using var db = await WithProfile(); db.Moderation.Add(TimeoutBan(Player, ModerationPolicy.AutomaticTimeoutBanMethod)); await db.SaveChangesAsync(); var appeal = new BanAppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var generated = await appeal.GenerateCode(); var payload = Assert.IsAssignableFrom(generated.Result).Value!; var type = payload.GetType(); Assert.False((bool)type.GetProperty("success")!.GetValue(payload)!); Assert.Equal(AccountLock.NotAppealableMessage, type.GetProperty("error")!.GetValue(payload) as string); // The poll the account status screen makes must not mint a code for one either: an empty // code is what keeps the appeal button off the screen. var reporting = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var details = await reporting.GetBlockDetails(); var detailsObject = Assert.IsType(Assert.IsAssignableFrom(details).Value); Assert.Equal("Timeout", detailsObject.BanType); Assert.Equal(string.Empty, detailsObject.BanAppealCode); Assert.Equal(string.Empty, detailsObject.BanAppealText); Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == Player)).AppealCode); } [Fact] public async Task AnAlreadyIssuedCodeCannotBeTurnedIntoATimeoutAppeal() { await using var db = await WithProfile(); var ban = TimeoutBan(Player, ModerationPolicy.ModeratorReportBanMethod); ban.AppealCode = "OLDTIMEOUT"; db.Moderation.Add(ban); await db.SaveChangesAsync(); var submitApi = new AppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var submitted = await submitApi.Submit(new SubmitAppealRequest { Code = "OLDTIMEOUT", Text = "please lift this timeout, it was not me" }); var refused = Assert.IsType(submitted.Result); Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType(refused.Value).Message); Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == Player)).AppealText); } [Fact] public async Task AVoiceModerationBanKeepsTheButtonAndRefusesTheAppeal() { await using var db = await WithProfile(); var ban = TimeoutBan(Player, ModerationPolicy.VoiceModerationBanMethod); ban.AppealCode = "VOICECODE"; db.Moderation.Add(ban); await db.SaveChangesAsync(); var appeal = new BanAppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var generated = await appeal.GenerateCode(); var payload = Assert.IsAssignableFrom(generated.Result).Value!; var type = payload.GetType(); Assert.False((bool)type.GetProperty("success")!.GetValue(payload)!); Assert.Equal(AccountLock.NotAppealableMessage, type.GetProperty("error")!.GetValue(payload) as string); // The ban is still a ban, so the row keeps the code the screen draws its button from. Assert.Equal("VOICECODE", (await db.Moderation.SingleAsync(x => x.PlayerId == Player)).AppealCode); var submitApi = new AppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var submitted = await submitApi.Submit(new SubmitAppealRequest { Code = "VOICECODE", Text = "this was a joke with friends, please reconsider" }); var refused = Assert.IsType(submitted.Result); Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType(refused.Value).Message); } [Fact] public async Task AModeratorReportIsAnHourLongTimeoutWithNoAppeal() { await using var db = await WithProfile(); db.Profiles.Add(new Profile { accountId = 7, username = "moderator", displayName = "Moderator", profileImage = "", role = "moderator" }); await db.SaveChangesAsync(); var before = DateTime.UtcNow; var api = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(7) } }; var result = await api.CreateReport(new CreateReportRequest { PlayerIdReported = Player, ReportCategory = ReportCategory.CoC_Trolling, Details = "griefing the lobby", RoomId = 1 }); Assert.IsType(result.Result); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player); Assert.True(ban.IsBan); Assert.Equal(ModerationPolicy.ModeratorReportBanMethod, ban.BanMethod); Assert.Null(ban.AppealCode); Assert.True(ban.ExpireDuration >= before.AddMinutes(59) && ban.ExpireDuration <= before.AddMinutes(61), $"a moderator report should be about an hour, was {ban.ExpireDuration - before}"); } [Fact] public async Task AFlipOfReportsTimesOutTheReportedPlayerWithoutAnAppeal() { await using var db = await WithProfile(); db.Profiles.Add(new Profile { accountId = 9, username = "reporter", displayName = "Reporter", profileImage = "", role = "" }); await db.SaveChangesAsync(); var api = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(9) } }; var before = DateTime.UtcNow; // The threshold is crossed somewhere in here; reporting the same player repeatedly is what // the automatic timeout exists to catch, so the last report must leave a timeout behind. for (var i = 0; i < 8; i++) { await api.CreateReport(new CreateReportRequest { PlayerIdReported = Player, ReportCategory = ReportCategory.CoC_Trolling, Details = $"report {i}", RoomId = 1 }); } var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player); Assert.True(ban.IsBan); Assert.Equal(ModerationPolicy.AutomaticTimeoutBanMethod, ban.BanMethod); Assert.Null(ban.AppealCode); Assert.Contains("timeout", ban.Message, StringComparison.OrdinalIgnoreCase); Assert.True(ban.ExpireDuration <= before.AddHours(24).AddMinutes(1), "an automatic timeout must not exceed a day"); } // ---------------------------------------------------------------- the admin panel's IsTimeout private const string AdminSecret = "test-bot-secret"; private static async Task AdminPatch(RecDBContext db, string json) { var payload = System.Text.Json.JsonDocument.Parse(json).RootElement.Clone(); // The panel reads its own session cookie for the action log, so the request has to exist. var api = new RadicadeServer.Admin.AdminPanelAPI(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var original = DBConfig.AdminApiKey; DBConfig.AdminApiKey = AdminSecret; try { return await api.Update("players", Player, AdminSecret, payload); } finally { DBConfig.AdminApiKey = original; } } [Fact] public async Task TheAdminIsTimeoutCheckboxMakesTheBanATimeoutWithNoAppeal() { await using var db = await WithProfile(); var before = DateTime.UtcNow; var result = await AdminPatch(db, """ { "banned": true, "ban_reason": "Trolling", "ban_type": "Temporary", "ban_category": 7, "ban_method": "Admin Panel", "ban_is_timeout": true } """); Assert.IsType(result); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player); Assert.True(ban.IsBan); Assert.Equal(ModerationPolicy.AutomaticTimeoutBanMethod, ban.BanMethod); Assert.Null(ban.AppealCode); Assert.Contains("timeout", ban.Message, StringComparison.OrdinalIgnoreCase); // IsTimeout keeps the duration inside the timeout window. Assert.True(ban.ExpireDuration >= before.AddMinutes(59) && ban.ExpireDuration <= before.AddHours(24).AddMinutes(1), $"a timeout must land between an hour and a day from now, was {ban.ExpireDuration - before}"); } [Fact] public async Task AnAdminTimeoutLongerThanADayIsClampedAndAnExistingAppealCodeIsDropped() { await using var db = await WithProfile(); db.Moderation.Add(new ModerationDB { PlayerId = Player, IsBan = true, Message = "old ban", Category = ReportCategory.Misc, BanMethod = "Admin Panel", AppealCode = "OLDCODE", ExpireDuration = DateTime.UtcNow.AddDays(30) }); await db.SaveChangesAsync(); var before = DateTime.UtcNow; var until = DateTime.UtcNow.AddDays(7).ToString("O"); var result = await AdminPatch(db, $$""" { "banned": true, "ban_reason": "Voice spam", "ban_type": "Temporary", "ban_category": 7, "ban_method": "Admin Panel", "ban_until": "{{until}}", "ban_is_timeout": true } """); Assert.IsType(result); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player); Assert.Equal(ModerationPolicy.AutomaticTimeoutBanMethod, ban.BanMethod); Assert.Null(ban.AppealCode); Assert.True(ban.ExpireDuration <= before.AddHours(24).AddMinutes(1), $"a seven day timeout ask must be clamped to a day, was {ban.ExpireDuration - before}"); } [Fact] public async Task AnAdminVoiceModerationBanKeepsACodeSoTheButtonIsDrawn() { await using var db = await WithProfile(); var result = await AdminPatch(db, """ { "banned": true, "ban_reason": "Sexually explicit behavior or photos", "ban_type": "Temporary", "ban_category": 2, "ban_method": "Voice Moderation", "ban_until": "2027-01-01T00:00:00.000Z" } """); Assert.IsType(result); var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player); Assert.Equal(ModerationPolicy.VoiceModerationBanMethod, ban.BanMethod); // The code is issued so the screen still draws the appeal button, and the appeal it opens // is then refused - the player is told the ban cannot be appealed instead of being left // staring at a screen with no way to ask. Assert.True(ModerationPolicy.IssuesAppealCode(ban.BanMethod)); Assert.True(ModerationPolicy.RefusesAppeal(ban.BanMethod)); // The code is what puts the button on the screen; the refusal happens when it is pressed. Assert.False(string.IsNullOrWhiteSpace(ban.AppealCode)); } // ---------------------------------------------------------------- what the client is told private static async Task Poll(RecDBContext db) { var reporting = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var result = await reporting.GetBlockDetails(); return Assert.IsType(Assert.IsAssignableFrom(result).Value); } [Fact] public async Task AVoiceBanTellsTheClientItIsOneSoItPrintsItsOwnSentence() { await using var db = await WithProfile(); db.Moderation.Add(new ModerationDB { PlayerId = Player, IsBan = true, Message = "Sexually explicit behavior or photos", Category = ReportCategory.CoC_Sexual, BanMethod = ModerationPolicy.VoiceModerationBanMethod, AppealCode = "VOICECODE", CreatedAt = DateTime.UtcNow, ExpireDuration = DateTime.UtcNow.AddDays(7) }); await db.SaveChangesAsync(); var details = await Poll(db); // The sentence the player reads for one of these is the client's own, chosen off this flag: // a voice ban that does not set it is drawn with the ordinary "After a review of reports, // you have received a ban for" headline instead, which is the bug this pins down. Assert.True(details.IsVoiceModAutoban); Assert.Equal("VoiceModeration", details.BanType); // The reason the moderator gave shows underneath that sentence, and the sentence itself is // not repeated into it. Assert.Equal("Sexually explicit behavior or photos", details.Message); Assert.DoesNotContain("voice moderation system", details.Message, StringComparison.OrdinalIgnoreCase); // The button comes from the code, and the text beside it is the refusal. Assert.Equal("VOICECODE", details.BanAppealCode); Assert.Equal(AccountLock.NotAppealableMessage, details.BanAppealText); } [Fact] public async Task AnOrdinaryBanClaimsNeitherAVoiceBanNorATimeout() { await using var db = await WithProfile(); db.Moderation.Add(new ModerationDB { PlayerId = Player, IsBan = true, Message = "Harassment", Category = ReportCategory.Harassment, BanMethod = "Admin Panel", CreatedAt = DateTime.UtcNow, ExpireDuration = DateTime.UtcNow.AddDays(3) }); await db.SaveChangesAsync(); var details = await Poll(db); Assert.False(details.IsVoiceModAutoban); Assert.Equal("Moderation", details.BanType); // An ordinary ban is shown with the reason as it was written. Assert.Equal("Harassment", details.Message); } [Fact] public async Task ATimeoutIsNotAVoiceBan() { await using var db = await WithProfile(); db.Moderation.Add(TimeoutBan(Player, ModerationPolicy.AutomaticTimeoutBanMethod)); await db.SaveChangesAsync(); var details = await Poll(db); Assert.False(details.IsVoiceModAutoban); Assert.Equal("Timeout", details.BanType); Assert.Equal("You have received a timeout due to a high number of reports for Trolling.", details.Message); // No code and no text: the appeal button and the support line under it are not drawn. Assert.Equal(string.Empty, details.BanAppealCode); Assert.Equal(string.Empty, details.BanAppealText); } [Fact] public async Task TheBodyPushedToABannedClientCarriesTheVoiceFlag() { await using var db = await WithProfile(); var ban = new PlayerUtils.PlayerBanInfo("Moderation", "Sexually explicit behavior or photos", DateTime.UtcNow.AddDays(7), "mod:9", ReportCategory.CoC_Sexual, AppealCode: "VOICECODE", StartedAtUtc: DateTime.UtcNow, BanMethod: ModerationPolicy.VoiceModerationBanMethod); var details = await PlayerUtils.BuildBanBlockDetailsAsync(db, ban, accountId: Player); Assert.True(details.IsVoiceModAutoban); Assert.Equal("VoiceModeration", details.BanType); Assert.Equal(AccountLock.NotAppealableMessage, details.BanAppealText); Assert.Equal("Sexually explicit behavior or photos", details.DisplayReason); } /* The client finds these by name, so the name is part of the contract. The API serializes with no naming policy - Program.cs sets PropertyNamingPolicy = null - which is why the field the client looks for is spelled the same in C# and on the wire. */ [Fact] public async Task TheVoiceFlagTravelsUnderTheNameTheClientLooksFor() { await using var db = await WithProfile(); db.Moderation.Add(new ModerationDB { PlayerId = Player, IsBan = true, Message = "Sexually explicit behavior or photos", Category = ReportCategory.CoC_Sexual, BanMethod = ModerationPolicy.VoiceModerationBanMethod, CreatedAt = DateTime.UtcNow, ExpireDuration = DateTime.UtcNow.AddDays(7) }); await db.SaveChangesAsync(); var details = await Poll(db); var json = JsonSerializer.Serialize(details, new JsonSerializerOptions { PropertyNamingPolicy = null }); Assert.Contains("\"IsVoiceModAutoban\":true", json); // The fields the client already reads out of this body are spelled the same way. Assert.Contains("\"IsBan\":true", json); Assert.Contains("\"BanAppealText\":", json); } [Fact] public async Task AnAdminVoiceModerationBanReachesTheClientAsAVoiceBan() { await using var db = await WithProfile(); var result = await AdminPatch(db, """ { "banned": true, "ban_reason": "Sexually explicit behavior or photos", "ban_type": "Temporary", "ban_category": 2, "ban_method": "Voice Moderation", "ban_until": "2027-01-01T00:00:00.000Z" } """); Assert.IsType(result); var details = await Poll(db); Assert.True(details.IsVoiceModAutoban); Assert.Equal("VoiceModeration", details.BanType); } // ---------------------------------------------------------------- voice restrictions [Fact] public async Task AVoiceRestrictionCutsVoiceChatAndNothingElse() { await using var db = await WithProfile(); Assert.Null(await VoiceChatRestrictions.ActiveAsync(db, Player)); var restriction = await VoiceChatRestrictions.ApplyAsync(db, Player, "slurs in voice chat", ReportCategory.CoC_Trolling, TimeSpan.FromHours(6), VoiceChatRestrictions.Method, "StaffOne", "warning given first"); Assert.Equal(Player, restriction.PlayerId); Assert.Equal(VoiceChatRestrictions.Method, restriction.IssuedBy); Assert.Equal("StaffOne", restriction.IssuedByUsername); var active = await VoiceChatRestrictions.ActiveAsync(db, Player); Assert.NotNull(active); // A restriction is not a ban: nothing may resolve as one, or the player is ejected and the // account status screen appears. Assert.Empty(await db.Moderation.Where(x => x.PlayerId == Player).ToListAsync()); Assert.Null(await db.ResolveBanAsync(Player)); var payload = VoiceChatRestrictions.Payload(active); var payloadType = payload.GetType(); Assert.True((bool)payloadType.GetProperty("restricted")!.GetValue(payload)!); Assert.Equal(restriction.ExpiresAt, (DateTime)payloadType.GetProperty("expiresAtUtc")!.GetValue(payload)!); Assert.True(await VoiceChatRestrictions.ClearAsync(db, Player, "StaffOne")); Assert.Null(await VoiceChatRestrictions.ActiveAsync(db, Player)); Assert.False(await VoiceChatRestrictions.ClearAsync(db, Player, "StaffOne")); // Lifted, not deleted: the history stays. var row = await db.VoiceChatRestrictions.SingleAsync(x => x.PlayerId == Player); Assert.NotNull(row.ClearedAt); Assert.Equal("StaffOne", row.ClearedBy); } [Fact] public async Task ARestrictionTellsThePlayerWhyInACoachMessage() { await using var db = await WithProfile(); var restriction = await VoiceChatRestrictions.ApplyAsync(db, Player, "slurs in voice chat", ReportCategory.CoC_Trolling, TimeSpan.FromHours(6), VoiceChatRestrictions.Method); Assert.NotNull(restriction); // What the player is told when the restriction lands, and who it comes from. The send // itself is best effort - it goes out through the notify hub, which a unit test has no // factory for - so the sentence and the sender are what can be pinned here. Assert.Equal( "You have had your voice privileges temporarily restricted due to speech which violates the Code of Conduct.", VoiceChatRestrictions.CoachMessage); Assert.Equal(1, VoiceChatRestrictions.CoachAccountId); } [Fact] public async Task ASecondRestrictionExtendsTheFirstAndNeverShortensIt() { await using var db = await WithProfile(); var first = await VoiceChatRestrictions.ApplyAsync(db, Player, "first", ReportCategory.Misc, TimeSpan.FromHours(12), VoiceChatRestrictions.Method); var longExpiry = first.ExpiresAt; var shortened = await VoiceChatRestrictions.ApplyAsync(db, Player, "second", ReportCategory.Misc, TimeSpan.FromHours(1), VoiceChatRestrictions.Method); Assert.Equal(longExpiry, shortened.ExpiresAt); Assert.Equal("second", shortened.Reason); var extended = await VoiceChatRestrictions.ApplyAsync(db, Player, "third", ReportCategory.Misc, TimeSpan.FromDays(30), VoiceChatRestrictions.Method); Assert.True(extended.ExpiresAt > longExpiry); Assert.True(extended.ExpiresAt <= DateTime.UtcNow.AddDays(30).AddMinutes(1)); Assert.Single(await db.VoiceChatRestrictions.Where(x => x.PlayerId == Player).ToListAsync()); } [Fact] public async Task TheClientEndpointAnswersWithTheStateTheClientBuildReads() { await using var db = await WithProfile(); var api = new RadicadeServer.API.Config.VoiceChatRestrictionAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } }; var clear = Assert.IsAssignableFrom(await api.GetRestriction()).Value!; Assert.False((bool)clear.GetType().GetProperty("restricted")!.GetValue(clear)!); await VoiceChatRestrictions.ApplyAsync(db, Player, "voice spam", ReportCategory.CoC_Trolling, TimeSpan.FromHours(6), VoiceChatRestrictions.Method); var restricted = Assert.IsAssignableFrom(await api.GetRestriction()).Value!; var type = restricted.GetType(); Assert.True((bool)type.GetProperty("restricted")!.GetValue(restricted)!); Assert.Equal("voice spam", type.GetProperty("reason")!.GetValue(restricted) as string); Assert.True((int)type.GetProperty("secondsRemaining")!.GetValue(restricted)! > 0); } [Fact] public async Task AnExpiredRestrictionIsNoRestriction() { await using var db = await WithProfile(); db.VoiceChatRestrictions.Add(new VoiceChatRestriction { PlayerId = Player, Reason = "expired", Category = ReportCategory.Misc, StartedAt = DateTime.UtcNow.AddHours(-5), ExpiresAt = DateTime.UtcNow.AddHours(-1), IssuedBy = VoiceChatRestrictions.Method }); await db.SaveChangesAsync(); Assert.Null(await VoiceChatRestrictions.ActiveAsync(db, Player)); var payload = VoiceChatRestrictions.Payload(null); Assert.False((bool)payload.GetType().GetProperty("restricted")!.GetValue(payload)!); } }