using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using RadicadeServer.API.Moderation; using RadicadeServer.DB; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Moderation; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The support site closes a ticket by telling the game what it decided, so the /// game endpoint behind that call is the only thing standing between a queue /// item and a player's appeal state. /// /// A ban appeal used to have exactly two verdicts, accept and deny. That left a /// ticket which could be neither accepted nor denied with no way out of the /// queue, and left staff choosing between lying about a verdict or never /// clearing the ticket. Reviewing is the third verdict: the ban stands, the /// appeal stops being pending, and the cooldown starts so the player can appeal /// again later instead of waiting on a decision that will never come. /// /// These tests pin the state each verdict leaves behind, because a wrong field /// here is invisible until a player is locked out of appealing. /// [Collection(DbFactoryCollection.Name)] public class BanAppealResolveTests { private const long PlayerId = 4242; private const string AppealCode = "NWVPZX5Q"; private const string SupportKey = "support-test-key"; /* A named in-memory store plus its root, so separate contexts in one test really do share a database. That is what makes the assertions below check what was saved rather than what happens to be attached to one context. */ private sealed class TestStore { private readonly string _name = Guid.NewGuid().ToString(); private readonly InMemoryDatabaseRoot _root = new(); public RecDBContext Open() => new( TestDbOptions.Create() .UseInMemoryDatabase(_name, _root) .Options); } private static ModerationDB Ban(string? appealText) => new() { PlayerId = PlayerId, IsBan = true, Message = "Cheating", Category = ReportCategory.CoC_Trolling, CreatedAt = DateTime.UtcNow.AddDays(-1), ExpireDuration = DateTime.UtcNow.AddDays(30), AppealCode = AppealCode, AppealText = appealText }; private static async Task SeedAsync(string? appealText) { var store = new TestStore(); await using var db = store.Open(); db.Moderation.Add(Ban(appealText)); await db.SaveChangesAsync(); return store; } private static async Task> ResolveAsync(TestStore store, string? decision) { var previous = DBConfig.AdminApiKey; DBConfig.AdminApiKey = SupportKey; try { await using var db = store.Open(); var controller = new BanAppealAPI(db); return await controller.Resolve(AppealCode, SupportKey, decision); } finally { DBConfig.AdminApiKey = previous; } } private static async Task ReloadAsync(TestStore store) { await using var db = store.Open(); return await db.Moderation.AsNoTracking().SingleAsync(x => x.PlayerId == PlayerId); } /* AppealResponse ships an anonymous { success, error, value }, so the payload is read reflectively rather than by adding a type just for tests. */ private static (bool Success, string? Error, string? Value) Read(ActionResult result) { var payload = result.Result switch { ObjectResult objectResult => objectResult.Value, _ => null }; Assert.NotNull(payload); var type = payload!.GetType(); var success = (bool)(type.GetProperty("success")!.GetValue(payload) ?? false); var error = type.GetProperty("error")!.GetValue(payload) as string; var value = type.GetProperty("value")!.GetValue(payload) as string; return (success, error, value); } [Fact] public async Task ReviewingAnAppealLeavesTheBanAndEndsThePendingState() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); var (success, error, value) = Read(await ResolveAsync(store, "reviewed")); Assert.True(success, error); Assert.Equal("Reviewed", value); var ban = await ReloadAsync(store); Assert.True(ban.IsBan); Assert.Equal("Reviewed", ban.AppealResolution); Assert.NotNull(ban.AppealResolvedAt); /* The appeal body stays as the player wrote it: it is the record of what was reviewed, not a flag to be cleared. */ Assert.Equal(PlayerUtils.SupportSiteAppealText, ban.AppealText); } /* The point of the verdict: a reviewed appeal is no longer pending, which is what the ban screen keys off, and the cooldown now applies so the player gets a fresh code once it lapses. */ [Fact] public async Task AReviewedAppealNoLongerLooksPendingAndStartsTheCooldown() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); Read(await ResolveAsync(store, "review")); var ban = await ReloadAsync(store); var pending = ban.AppealText != null && ban.AppealResolution == null; Assert.False(pending); await using var db = store.Open(); var cooldown = await PlayerUtils.GetAppealCooldownErrorAsync(db, PlayerId); Assert.NotNull(cooldown); } [Fact] public async Task AcceptingAnAppealLiftsTheBan() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); var (success, error, value) = Read(await ResolveAsync(store, "accept")); Assert.True(success, error); Assert.Equal("Accepted", value); var ban = await ReloadAsync(store); Assert.False(ban.IsBan); Assert.Equal("Accepted", ban.AppealResolution); } [Fact] public async Task DenyingAnAppealKeepsTheBanAndStartsTheCooldown() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); var (success, error, value) = Read(await ResolveAsync(store, "deny")); Assert.True(success, error); Assert.Equal("Rejected", value); var ban = await ReloadAsync(store); Assert.True(ban.IsBan); Assert.Equal("Rejected", ban.AppealResolution); Assert.NotNull(ban.AppealResolvedAt); } /* A second click, a retry after a timeout, or a decision the admin panel already made must never overwrite the verdict that is already there. */ [Fact] public async Task AnExistingVerdictIsNeverOverwritten() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); Read(await ResolveAsync(store, "deny")); var (success, error, value) = Read(await ResolveAsync(store, "reviewed")); Assert.True(success, error); Assert.Equal("Rejected", value); Assert.Equal("Rejected", (await ReloadAsync(store)).AppealResolution); } [Fact] public async Task AnUnknownDecisionChangesNothing() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); var (success, error, value) = Read(await ResolveAsync(store, "maybe")); Assert.False(success); Assert.Equal("Unknown decision.", error); Assert.Null(value); var ban = await ReloadAsync(store); Assert.True(ban.IsBan); Assert.Null(ban.AppealResolution); Assert.Null(ban.AppealResolvedAt); } [Fact] public async Task TheWrongSecretIsRefusedWithoutTouchingTheAppeal() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); var previous = DBConfig.AdminApiKey; DBConfig.AdminApiKey = SupportKey; ActionResult result; try { await using var db = store.Open(); result = await new BanAppealAPI(db).Resolve(AppealCode, "not-the-key", "accept"); } finally { DBConfig.AdminApiKey = previous; } Assert.IsType(result.Result); var ban = await ReloadAsync(store); Assert.True(ban.IsBan); Assert.Null(ban.AppealResolution); } [Fact] public async Task AnUnknownCodeIsRefusedWithoutTouchingAnything() { var store = await SeedAsync(PlayerUtils.SupportSiteAppealText); var previous = DBConfig.AdminApiKey; DBConfig.AdminApiKey = SupportKey; ActionResult result; try { await using var db = store.Open(); result = await new BanAppealAPI(db).Resolve("NOSUCHCODE", SupportKey, "reviewed"); } finally { DBConfig.AdminApiKey = previous; } var (success, error, _) = Read(result); Assert.False(success); Assert.Equal("Unknown appeal code.", error); Assert.Null((await ReloadAsync(store)).AppealResolution); } }