mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-27 16:09:07 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<TestStore> 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<ActionResult<object>> 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<ModerationDB> 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<object> 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<object> 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<UnauthorizedObjectResult>(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<object> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user