Files
Radicade/ServerTests/ModerationTimeoutVoiceTests.cs
T
2026-10-16 23:33:13 -07:00

663 lines
28 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class ModerationTimeoutVoiceTests
{
private const long Player = 42;
private static RecDBContext NewDb() => new(
TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static async Task<RecDBContext> 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<ObjectResult>(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<BlockDetails>(Assert.IsAssignableFrom<ObjectResult>(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<BadRequestObjectResult>(submitted.Result);
Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType<OkResponse>(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<ObjectResult>(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<BadRequestObjectResult>(submitted.Result);
Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType<OkResponse>(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<OkObjectResult>(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<IActionResult> 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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<BlockDetails> Poll(RecDBContext db)
{
var reporting = new PlayerReportingAPI(db)
{
ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) }
};
var result = await reporting.GetBlockDetails();
return Assert.IsType<BlockDetails>(Assert.IsAssignableFrom<ObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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)!);
}
}