mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,977 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using RadicadeServer.API.Moderation;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Enums;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Enums.Moderation;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
using RadicadeServer.RecNet.Messages;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using RadicadeServer.Utils;
|
||||
using Xunit;
|
||||
|
||||
namespace RadicadeServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The moderator warning endpoints (<c>POST api/playerwarnings</c> and
|
||||
/// <c>POST api/playerwarnings/acknowledge</c>).
|
||||
///
|
||||
/// The 2023 client has the whole feature - the "Warn Player" action, a warn screen with an
|
||||
/// "Acknowledge warning" button, and both route literals in its RecNet layer - but this server
|
||||
/// mapped neither route, so every warn 404'd. A 404 there is silent: nothing logs, the button
|
||||
/// just does nothing, which is why it went unnoticed.
|
||||
///
|
||||
/// The route assertions are the point of this file, for the same reason they are in
|
||||
/// RoleLookupTests: a correct handler behind a missing prefix is the failure mode this project
|
||||
/// keeps hitting. These two templates are absolute (they start with "/"), so ASP.NET ignores the
|
||||
/// controller's own <c>api/PlayerReporting/</c> prefix - the client's literal is the whole path.
|
||||
/// </summary>
|
||||
[Collection(DbFactoryCollection.Name)]
|
||||
public class PlayerWarningTests
|
||||
{
|
||||
// Mirrors the server's MVC JSON options, including PropertyNameCaseInsensitive - which is
|
||||
// what makes System.Text.Json reject a type whose members differ only in case. Without it a
|
||||
// case-colliding response DTO serializes fine in a test and 500s in production.
|
||||
private static readonly JsonSerializerOptions ServerJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson);
|
||||
|
||||
private static RecDBContext NewDb() => new(
|
||||
TestDbOptions.Create()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
|
||||
private static PlayerReportingAPI NewApi(RecDBContext db, long? callerId = 6)
|
||||
{
|
||||
var identity = new ClaimsIdentity("TestAuth");
|
||||
if (callerId != null)
|
||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, callerId.Value.ToString()));
|
||||
|
||||
return new PlayerReportingAPI(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Give the controller a request body the way the client sends it.</summary>
|
||||
private static void SetBody(Controller controller, string body, string contentType)
|
||||
{
|
||||
var request = controller.ControllerContext.HttpContext.Request;
|
||||
request.ContentType = contentType;
|
||||
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
|
||||
}
|
||||
|
||||
private static async Task<RecDBContext> WithProfileAsync(long accountId, string? roles)
|
||||
{
|
||||
var db = NewDb();
|
||||
db.Profiles.Add(new Profile
|
||||
{
|
||||
accountId = accountId,
|
||||
role = roles,
|
||||
username = $"player{accountId}",
|
||||
displayName = $"Player {accountId}",
|
||||
profileImage = DBConfig.DefaultProfileImage
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Routes
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
private static string[] PostTemplates(Type controller, string methodName) =>
|
||||
controller.GetMethod(methodName)!.GetCustomAttributes<HttpPostAttribute>()
|
||||
.Select(a => a.Template)
|
||||
.Where(t => t != null)
|
||||
.Select(t => t!)
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void TheWarningRoutesAreTheAbsolutePathsTheClientBuilds()
|
||||
{
|
||||
var create = PostTemplates(typeof(PlayerReportingAPI), nameof(PlayerReportingAPI.CreateWarning));
|
||||
var acknowledge = PostTemplates(typeof(PlayerReportingAPI), nameof(PlayerReportingAPI.AcknowledgeWarning));
|
||||
|
||||
// The client's own literals, verbatim from its metadata string table.
|
||||
Assert.Contains("/api/playerwarnings", create);
|
||||
Assert.Contains("/api/playerwarnings/acknowledge", acknowledge);
|
||||
|
||||
// Every one of them absolute: a template that started with "api/" would be concatenated
|
||||
// onto the controller's "api/PlayerReporting/" prefix and answer a path no client asks for.
|
||||
Assert.All(create.Concat(acknowledge), template => Assert.StartsWith("/", template));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Issuing a warning
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task AModeratorWarnsAPlayerAndTheModeratorComesFromTheToken()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "developer,moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=205&ReportCategory=101&DisplayReason=Sexual gestures&ModeratorNote=dfg",
|
||||
"application/x-www-form-urlencoded");
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
using var json = JsonDocument.Parse(Serialize(result.Value));
|
||||
Assert.True(json.RootElement.GetProperty("Success").GetBoolean());
|
||||
|
||||
var warning = await db.PlayerWarnings.SingleAsync();
|
||||
Assert.Equal(6, warning.ModeratorPlayerId); // the caller, never a body field
|
||||
Assert.Equal(205, warning.WarnedPlayerId);
|
||||
Assert.Equal(ReportCategory.CoC_Sexual, warning.ReportCategory);
|
||||
Assert.Equal("Sexual gestures", warning.DisplayReason);
|
||||
Assert.Equal("dfg", warning.ModeratorNote);
|
||||
Assert.Null(warning.AcknowledgedAt);
|
||||
Assert.NotEqual(default, warning.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbsentFieldsAreStoredRatherThanInvented()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=206", "application/x-www-form-urlencoded");
|
||||
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
|
||||
var warning = await db.PlayerWarnings.SingleAsync();
|
||||
Assert.Equal(0, (int)warning.ReportCategory);
|
||||
Assert.Null(warning.DisplayReason);
|
||||
Assert.Null(warning.ModeratorNote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WarningsAreAppendOnly()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
|
||||
foreach (var note in new[] { "first", "second" })
|
||||
{
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, $"WarnedPlayerId=207&ModeratorNote={note}", "application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
}
|
||||
|
||||
var rows = await db.PlayerWarnings.Where(x => x.WarnedPlayerId == 207).OrderBy(x => x.Id).ToListAsync();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Equal(new[] { "first", "second" }, rows.Select(r => r.ModeratorNote));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheJSONBodyShapeBindsTheSameWayAsTheFormBody()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api,
|
||||
"""{"WarnedPlayerId":"208","ReportCategory":"103","DisplayReason":"griefing","ModeratorNote":"n"}""",
|
||||
"application/json");
|
||||
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
|
||||
var warning = await db.PlayerWarnings.SingleAsync();
|
||||
Assert.Equal(208, warning.WarnedPlayerId);
|
||||
Assert.Equal(ReportCategory.CoC_Trolling, warning.ReportCategory);
|
||||
Assert.Equal("griefing", warning.DisplayReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AWarningWithoutAWarnedPlayerIsRefused()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "ModeratorNote=dfg", "application/x-www-form-urlencoded");
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(await api.CreateWarning());
|
||||
Assert.Empty(db.PlayerWarnings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("gameClient")]
|
||||
public async Task AValidTokenWithoutAStaffRoleIsRefusedAndNothingIsWritten(string? roles)
|
||||
{
|
||||
using var db = await WithProfileAsync(9, roles);
|
||||
var api = NewApi(db, 9);
|
||||
SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded");
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(await api.CreateWarning());
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.Empty(db.PlayerWarnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlayerWithNoProfileRowCannotWarnAnyone()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded");
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(await api.CreateWarning());
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Delivering a warning, and acknowledging it
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task APendingWarningRidesTheNoBanBlockDetailsResponseAsTheWarnScreen()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=205&ReportCategory=101&DisplayReason=Sexual gestures",
|
||||
"application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
|
||||
var warned = NewApi(db, 205);
|
||||
var ok = Assert.IsType<OkObjectResult>(await warned.GetBlockDetails());
|
||||
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
||||
|
||||
Assert.True(json.RootElement.GetProperty("IsWarning").GetBoolean());
|
||||
Assert.False(json.RootElement.GetProperty("IsBan").GetBoolean());
|
||||
// Duration drives the BAN screen whenever it is > 0, so a warning must leave it at 0 or
|
||||
// the player is shown a ban countdown for a warning.
|
||||
Assert.Equal(0, json.RootElement.GetProperty("Duration").GetInt32());
|
||||
Assert.Equal("Sexual gestures", json.RootElement.GetProperty("Message").GetString());
|
||||
Assert.Equal(6, json.RootElement.GetProperty("PlayerIdReporter").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgingClearsOnlyTheCallersPendingWarnings()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
foreach (var warned in new[] { 205L, 206L })
|
||||
{
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, $"WarnedPlayerId={warned}", "application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
}
|
||||
|
||||
// The acknowledge posts no body at all on some builds, so this call sends none.
|
||||
var result = Assert.IsType<OkObjectResult>(await NewApi(db, 205).AcknowledgeWarning());
|
||||
using var json = JsonDocument.Parse(Serialize(result.Value));
|
||||
Assert.True(json.RootElement.GetProperty("Success").GetBoolean());
|
||||
|
||||
Assert.NotNull((await db.PlayerWarnings.SingleAsync(x => x.WarnedPlayerId == 205)).AcknowledgedAt);
|
||||
Assert.Null((await db.PlayerWarnings.SingleAsync(x => x.WarnedPlayerId == 206)).AcknowledgedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAcknowledgedWarningLeavesThePlainEmptyBlockDetails()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
|
||||
Assert.IsType<OkObjectResult>(await NewApi(db, 205).AcknowledgeWarning());
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(await NewApi(db, 205).GetBlockDetails());
|
||||
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
||||
|
||||
// Back to the four-key empty object the no-ban response has always been.
|
||||
Assert.Equal(4, json.RootElement.EnumerateObject().Count());
|
||||
Assert.Equal(0, json.RootElement.GetProperty("Duration").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ABanOutranksAPendingWarning()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
|
||||
db.Moderation.Add(new ModerationDB
|
||||
{
|
||||
PlayerId = 205,
|
||||
IsBan = true,
|
||||
Message = "banned",
|
||||
Category = ReportCategory.Misc,
|
||||
ExpireDuration = DateTime.UtcNow.AddHours(1)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(await NewApi(db, 205).GetBlockDetails());
|
||||
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
||||
|
||||
Assert.False(json.RootElement.GetProperty("IsWarning").GetBoolean());
|
||||
Assert.True(json.RootElement.GetProperty("IsBan").GetBoolean());
|
||||
Assert.True(json.RootElement.GetProperty("Duration").GetInt32() > 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Delivering it: out of the room, into the dorm, and NOT as a chat message
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The static factories <c>RoomUtils</c> and <c>MatchmakingUtils</c> reach for when they are
|
||||
/// called from a request that only has its own context - the same pattern the other
|
||||
/// factory-dependent suites use.
|
||||
/// </summary>
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
||||
{
|
||||
private readonly DbContextOptions<RecDBContext> options;
|
||||
|
||||
public TestDbContextFactory()
|
||||
{
|
||||
options = TestDbOptions.Create()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
}
|
||||
|
||||
public RecDBContext CreateDbContext() => new(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Room 1 (the dorm template, cloned per player) plus a public room the player is standing
|
||||
/// in - the only state that makes an ejection observable.
|
||||
/// </summary>
|
||||
private static async Task<(TestDbContextFactory Factory, RecDBContext Db, RoomInstance Room)>
|
||||
APlayerInARoomAsync(long playerId, bool alreadyInTheirOwnDorm = false)
|
||||
{
|
||||
var factory = new TestDbContextFactory();
|
||||
RoomUtils.DbFactory = factory;
|
||||
MatchmakingUtils.DbFactory = factory;
|
||||
|
||||
var db = factory.CreateDbContext();
|
||||
|
||||
db.Rooms.Add(new RoomData
|
||||
{
|
||||
RoomId = 1,
|
||||
Name = "DormRoom",
|
||||
IsDorm = true,
|
||||
CreatorAccountId = 1,
|
||||
Accessibility = Accessibility.Private,
|
||||
MaxPlayers = 4,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
db.SubRooms.Add(new SubRoom
|
||||
{
|
||||
RoomId = 1,
|
||||
Name = "DormRoom",
|
||||
UnitySceneId = "DormRoomScene",
|
||||
MaxPlayers = 4,
|
||||
Accessibility = Accessibility.Private,
|
||||
DataBlob = "",
|
||||
});
|
||||
|
||||
db.Rooms.Add(new RoomData
|
||||
{
|
||||
RoomId = 25,
|
||||
Name = "RecCenter",
|
||||
CreatorAccountId = 1,
|
||||
Accessibility = Accessibility.Public,
|
||||
MaxPlayers = 20,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
db.SubRooms.Add(new SubRoom
|
||||
{
|
||||
RoomId = 25,
|
||||
Name = "Home",
|
||||
UnitySceneId = "RecCenterScene",
|
||||
MaxPlayers = 20,
|
||||
Accessibility = Accessibility.Public,
|
||||
});
|
||||
|
||||
// The player's own dorm, for the "already in it" case: a private room they created.
|
||||
if (alreadyInTheirOwnDorm)
|
||||
{
|
||||
db.Rooms.Add(new RoomData
|
||||
{
|
||||
RoomId = 77,
|
||||
Name = "DormRoom",
|
||||
IsDorm = true,
|
||||
CreatorAccountId = playerId,
|
||||
Accessibility = Accessibility.Private,
|
||||
MaxPlayers = 4,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
db.SubRooms.Add(new SubRoom
|
||||
{
|
||||
RoomId = 77,
|
||||
Name = "DormRoom",
|
||||
UnitySceneId = "DormRoomScene",
|
||||
MaxPlayers = 4,
|
||||
Accessibility = Accessibility.Private,
|
||||
DataBlob = "",
|
||||
});
|
||||
}
|
||||
|
||||
var instance = new RoomInstance
|
||||
{
|
||||
roomId = alreadyInTheirOwnDorm ? 77 : 25,
|
||||
subRoomId = 0,
|
||||
roomInstanceType = RoomInstanceType.Public,
|
||||
isPrivate = alreadyInTheirOwnDorm,
|
||||
maxCapacity = 20,
|
||||
name = alreadyInTheirOwnDorm ? "^DormRoom" : "^RecCenter",
|
||||
location = alreadyInTheirOwnDorm ? "DormRoomScene" : "RecCenterScene",
|
||||
};
|
||||
db.RoomInstances.Add(instance);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Presences.Add(new RealPresence
|
||||
{
|
||||
playerId = playerId,
|
||||
isOnline = true,
|
||||
roomInstance = instance.roomInstanceId,
|
||||
errorCode = JoinStatus.Success,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (factory, db, instance);
|
||||
}
|
||||
|
||||
/// <summary>The room a player's presence currently points at, with the room row behind it.</summary>
|
||||
private static async Task<(RoomInstance Instance, RoomData Room)> WhereIsAsync(RecDBContext db, long playerId)
|
||||
{
|
||||
var presence = await db.Presences.AsNoTracking().FirstAsync(x => x.playerId == playerId);
|
||||
var instance = await db.RoomInstances.AsNoTracking().FirstAsync(x => x.roomInstanceId == presence.roomInstance);
|
||||
var room = await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == instance.roomId);
|
||||
return (instance, room);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AWarningIsNotDeliveredAsACoachMessage()
|
||||
{
|
||||
var (factory, db, _) = await APlayerInARoomAsync(205);
|
||||
|
||||
// Notify.DbFactory is wired to the SAME store on purpose. With it null, SendMessage returns
|
||||
// immediately and the assertion below would pass even if a warning still tried to deliver
|
||||
// itself as a coach message; with it pointed somewhere else, the row would land in the other
|
||||
// database. Pointing it at this store is what makes "no message was written" mean it.
|
||||
var previousFactory = Notify.DbFactory;
|
||||
try
|
||||
{
|
||||
Notify.DbFactory = factory;
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: 205);
|
||||
|
||||
Assert.Empty(await db.Messages.ToListAsync());
|
||||
|
||||
// Positive control: this exact wiring does see a message when one is sent, so the empty
|
||||
// result above is the warning's doing rather than a harness that cannot see writes.
|
||||
await Notify.SendMessage(205, new Message
|
||||
{
|
||||
Type = MessageType.CoachMessage,
|
||||
Data = "control",
|
||||
FromPlayerId = 1,
|
||||
});
|
||||
Assert.Single(await db.Messages.ToListAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Notify.DbFactory = previousFactory;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AWarningTakesThePlayerOutOfTheRoomTheyAreInAndIntoTheirDorm()
|
||||
{
|
||||
const long playerId = 205;
|
||||
var (_, db, wasIn) = await APlayerInARoomAsync(playerId);
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: playerId);
|
||||
|
||||
var (nowIn, room) = await WhereIsAsync(db, playerId);
|
||||
|
||||
// Out of the room they were warned in...
|
||||
Assert.NotEqual(wasIn.roomInstanceId, nowIn.roomInstanceId);
|
||||
|
||||
// ...and standing in their own dorm, which is what makes them face the warn screen on the
|
||||
// room load that follows.
|
||||
Assert.True(room.IsDorm);
|
||||
Assert.Equal(playerId, room.CreatorAccountId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AWarningDoesNotBounceAPlayerOutOfTheirOwnDorm()
|
||||
{
|
||||
const long playerId = 206;
|
||||
var (_, db, wasIn) = await APlayerInARoomAsync(playerId, alreadyInTheirOwnDorm: true);
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: playerId);
|
||||
|
||||
var (nowIn, room) = await WhereIsAsync(db, playerId);
|
||||
|
||||
Assert.Equal(wasIn.roomInstanceId, nowIn.roomInstanceId);
|
||||
Assert.True(room.IsDorm);
|
||||
}
|
||||
|
||||
/// <summary>A moderator issues a warning to <paramref name="warnedPlayerId"/> through the route.</summary>
|
||||
private static async Task WithModeratorAsync(RecDBContext db, long warnedPlayerId)
|
||||
{
|
||||
// Only once: the moderator row has accountId as its key, so a second call for a second
|
||||
// warning would be a duplicate insert rather than a second warn.
|
||||
if (!await db.Profiles.AnyAsync(x => x.accountId == 6))
|
||||
{
|
||||
db.Profiles.Add(new Profile
|
||||
{
|
||||
accountId = 6,
|
||||
role = "moderator",
|
||||
username = "mod",
|
||||
displayName = "Mod",
|
||||
profileImage = DBConfig.DefaultProfileImage
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, $"WarnedPlayerId={warnedPlayerId}&ReportCategory=101", "application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
}
|
||||
|
||||
/// <summary>Adds the developer role a target needs to be asked before anything is applied.</summary>
|
||||
private static async Task WithDeveloperProfileAsync(RecDBContext db, long accountId)
|
||||
{
|
||||
db.Profiles.Add(new Profile
|
||||
{
|
||||
accountId = accountId,
|
||||
role = "developer",
|
||||
username = $"dev{accountId}",
|
||||
displayName = $"Dev {accountId}",
|
||||
profileImage = DBConfig.DefaultProfileImage
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Posts one voteToKick body as <paramref name="callerId"/> and requires it to be accepted.</summary>
|
||||
private static async Task AnswerVoteKickAsync(RecDBContext db, long callerId, string body)
|
||||
{
|
||||
var api = NewApi(db, callerId);
|
||||
SetBody(api, body, "application/x-www-form-urlencoded");
|
||||
|
||||
// The route is declared ActionResult<OkResponse>, so the object result is on .Result.
|
||||
var result = await api.VoteToKick();
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
Assert.True(((OkResponse)ok.Value!).Success);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Cannot be bypassed: a pending warning holds the player in their dorm until they
|
||||
// acknowledge it. This is the whole point of the feature - the row alone is not enough,
|
||||
// because leaving the dorm used to clear nothing and block nothing.
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task APendingWarningAnswersTheRoomJoinWithTheirDormInsteadOfTheRoomTheyAskedFor()
|
||||
{
|
||||
const long playerId = 220;
|
||||
var (_, db, wasIn) = await APlayerInARoomAsync(playerId);
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: playerId);
|
||||
|
||||
// The play-menu join, which is also what goto/room/{name} and matchmake/room/{id} run.
|
||||
var match = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "RecCenter", playerId);
|
||||
|
||||
// Answered as a successful matchmake into the dorm rather than as an error, so the client
|
||||
// loads the dorm it is already in with the warning screen still up.
|
||||
Assert.Equal(JoinStatus.Success, match.errorCode);
|
||||
Assert.NotNull(match.roomInstance);
|
||||
|
||||
var (nowIn, room) = await WhereIsAsync(db, playerId);
|
||||
Assert.True(room.IsDorm);
|
||||
Assert.Equal(playerId, room.CreatorAccountId);
|
||||
Assert.Equal(match.roomInstance!.roomInstanceId, nowIn.roomInstanceId);
|
||||
Assert.NotEqual(wasIn.roomInstanceId, nowIn.roomInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APendingWarningAlsoHoldsTheInstanceIdJoinsShut()
|
||||
{
|
||||
const long playerId = 221;
|
||||
var (_, db, wasIn) = await APlayerInARoomAsync(playerId);
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: playerId);
|
||||
|
||||
// JoinRoom is the funnel behind a friend's presence, an invite, a deep link, a clubhouse and
|
||||
// an event - the doors that used to let a warned player walk straight back out.
|
||||
var match = await MatchmakingUtils.JoinRoom(playerId, wasIn.roomInstanceId);
|
||||
|
||||
var (nowIn, room) = await WhereIsAsync(db, playerId);
|
||||
Assert.True(room.IsDorm);
|
||||
Assert.NotEqual(wasIn.roomInstanceId, nowIn.roomInstanceId);
|
||||
Assert.Equal(match.roomInstance!.roomInstanceId, nowIn.roomInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgingIsWhatLiftsTheHoldNotLeavingTheDorm()
|
||||
{
|
||||
const long playerId = 222;
|
||||
var (_, db, _) = await APlayerInARoomAsync(playerId);
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: playerId);
|
||||
|
||||
// Still pending: the warning was delivered, not answered.
|
||||
Assert.True(await db.HasPendingWarningAsync(playerId));
|
||||
var held = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "RecCenter", playerId);
|
||||
Assert.True((await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == held.roomInstance!.roomId)).IsDorm);
|
||||
|
||||
Assert.IsType<OkObjectResult>(await NewApi(db, playerId).AcknowledgeWarning());
|
||||
|
||||
var released = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "RecCenter", playerId);
|
||||
var room = await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == released.roomInstance!.roomId);
|
||||
Assert.Equal("RecCenter", room.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APendingWarningStillLetsThePlayerIntoTheirOwnDorm()
|
||||
{
|
||||
const long playerId = 223;
|
||||
var (_, db, _) = await APlayerInARoomAsync(playerId, alreadyInTheirOwnDorm: true);
|
||||
|
||||
await WithModeratorAsync(db, warnedPlayerId: playerId);
|
||||
|
||||
// Their own dorm is where the warning is answered, so it is not held shut - a warned player
|
||||
// has to be able to get back into the room the warning screen lives in.
|
||||
var match = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "DormRoom", playerId);
|
||||
|
||||
Assert.Equal(JoinStatus.Success, match.errorCode);
|
||||
Assert.NotNull(match.roomInstance);
|
||||
|
||||
var room = await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == match.roomInstance!.roomId);
|
||||
Assert.True(room.IsDorm);
|
||||
Assert.Equal(playerId, room.CreatorAccountId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheServedWarningKeepsDurationAtZeroAndOnlyTheDeliveryCarriesTheScreenHold()
|
||||
{
|
||||
var warning = new PlayerWarning
|
||||
{
|
||||
Id = 1,
|
||||
ModeratorPlayerId = 6,
|
||||
WarnedPlayerId = 205,
|
||||
DisplayReason = "Sexual gestures",
|
||||
};
|
||||
|
||||
var served = PlayerUtils.BuildWarningBlockDetails(warning);
|
||||
var delivered = PlayerUtils.BuildWarningBlockDetails(warning, (int)PlayerUtils.WarningScreenHold.TotalSeconds);
|
||||
|
||||
// What moderationBlockDetails answers with: the warn screen's text, and no duration - the
|
||||
// pending state is not a timeout.
|
||||
Assert.True(served.IsWarning);
|
||||
Assert.False(served.IsBan);
|
||||
Assert.Equal(0, served.Duration);
|
||||
Assert.Equal("Sexual gestures", served.Message);
|
||||
|
||||
// What is pushed at the client: the client only holds the dorm's screen up (and so only ever
|
||||
// asks its warn screen for text) when the delivered frame carries Duration > 0. IsBan stays
|
||||
// false, which is what keeps it the warn screen and not the ban screen.
|
||||
Assert.True(delivered.IsWarning);
|
||||
Assert.False(delivered.IsBan);
|
||||
Assert.True(delivered.Duration > 0);
|
||||
Assert.Equal("Sexual gestures", delivered.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AWarningIssuedWhileThePlayerIsOfflineStaysPending()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
|
||||
// Player 205 has no presence row at all, and no notify connection is registered, so both the
|
||||
// presence push and the two frames are dropped. The warning is written BEFORE either, which
|
||||
// is why a player who is offline when they are warned is still held when they come back.
|
||||
await WithModeratorAsync(db, 205);
|
||||
|
||||
Assert.True(await db.HasPendingWarningAsync(205));
|
||||
Assert.Empty(await db.Messages.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WarningsStackAndOneAcknowledgementClearsThemAll()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
|
||||
await WithModeratorAsync(db, 224);
|
||||
await WithModeratorAsync(db, 224);
|
||||
|
||||
var rows = await db.PlayerWarnings.Where(x => x.WarnedPlayerId == 224).ToListAsync();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, row => Assert.Null(row.AcknowledgedAt));
|
||||
|
||||
Assert.IsType<OkObjectResult>(await NewApi(db, 224).AcknowledgeWarning());
|
||||
|
||||
Assert.False(await db.HasPendingWarningAsync(224));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondAcknowledgementIsANoOpRatherThanAnError()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithModeratorAsync(db, 225);
|
||||
|
||||
Assert.IsType<OkObjectResult>(await NewApi(db, 225).AcknowledgeWarning());
|
||||
|
||||
// The button is already pressed, or the screen was re-opened after it closed: the second
|
||||
// post has nothing left to clear and must still succeed, or the client hangs on its screen.
|
||||
Assert.IsType<OkObjectResult>(await NewApi(db, 225).AcknowledgeWarning());
|
||||
Assert.NotNull((await db.PlayerWarnings.SingleAsync()).AcknowledgedAt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Developers: the server asks, waits, and only then applies.
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task AWarningForADeveloperIsParkedAndNothingIsWritten()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 230);
|
||||
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=230&ReportCategory=101&DisplayReason=Sexual gestures&ModeratorNote=dfg",
|
||||
"application/x-www-form-urlencoded");
|
||||
Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
|
||||
// Parked, not issued: a warning that exists is a warning that pins its player to the dorm,
|
||||
// and the developer has not decided whether to accept it.
|
||||
Assert.Empty(await db.PlayerWarnings.ToListAsync());
|
||||
|
||||
var decision = PlayerUtils.TakeDeveloperDecision(230);
|
||||
Assert.NotNull(decision);
|
||||
Assert.Equal(PlayerUtils.DeveloperDecisionKind.Warning, decision!.Kind);
|
||||
Assert.Equal("Sexual gestures", decision.DisplayReason);
|
||||
Assert.Equal("dfg", decision.ModeratorNote);
|
||||
Assert.Equal(6, decision.IssuedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADeveloperAcceptingTheWarningGetsItWrittenAsTheModeratorsWarning()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 231);
|
||||
await WithModeratorAsync(db, 231);
|
||||
|
||||
await AnswerVoteKickAsync(db, 231, "PlayerId=231&Response=true&GameSessionId=9301");
|
||||
|
||||
var warning = await db.PlayerWarnings.SingleAsync();
|
||||
Assert.Equal(231, warning.WarnedPlayerId);
|
||||
Assert.Equal(6, warning.ModeratorPlayerId); // the moderator who issued it, not the developer who accepted
|
||||
Assert.Equal(ReportCategory.CoC_Sexual, warning.ReportCategory);
|
||||
|
||||
// Accepting is not acknowledging: the warning is delivered now and stays pending until the
|
||||
// developer presses acknowledge on the warn screen.
|
||||
Assert.Null(warning.AcknowledgedAt);
|
||||
Assert.True(await db.HasPendingWarningAsync(231));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADeveloperDenyingTheWarningLeavesNothingBehind()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 232);
|
||||
await WithModeratorAsync(db, 232);
|
||||
|
||||
await AnswerVoteKickAsync(db, 232, "PlayerId=232&Response=false&GameSessionId=9302");
|
||||
|
||||
// Deny means deny: no warning to hold them, no moderation row, nothing pending to clear.
|
||||
Assert.Empty(await db.PlayerWarnings.ToListAsync());
|
||||
Assert.Empty(await db.Moderation.ToListAsync());
|
||||
Assert.False(await db.HasPendingWarningAsync(232));
|
||||
Assert.Null(PlayerUtils.TakeDeveloperDecision(232));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARepeatedAnswerCannotApplyTheWarningTwice()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 233);
|
||||
await WithModeratorAsync(db, 233);
|
||||
|
||||
await AnswerVoteKickAsync(db, 233, "PlayerId=233&Response=true&GameSessionId=9303");
|
||||
await AnswerVoteKickAsync(db, 233, "PlayerId=233&Response=true&GameSessionId=9303");
|
||||
|
||||
Assert.Single(await db.PlayerWarnings.ToListAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A parked prompt whose developer is not connected can no longer be answered, so it must not
|
||||
/// keep the slot. It used to: the client that would have shown the dialog was gone, nothing was
|
||||
/// written when the action was parked, and every later warning aimed at that developer was
|
||||
/// dropped with only a console line - the panel reported a prompt as sent and the developer's
|
||||
/// client was never asked anything again for the life of the process.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ADeveloperPromptNothingCouldReceiveIsReplacedInsteadOfWedging()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 234);
|
||||
|
||||
var warning = new PlayerWarning { WarnedPlayerId = 234, DisplayReason = "Trolling" };
|
||||
Assert.True(await PlayerUtils.DeferForDeveloperAsync(
|
||||
db, 234, PlayerUtils.DeveloperDecisionKind.Warning, PlayerUtils.BuildWarningBlockDetails(warning, 60)));
|
||||
Assert.Null(Notify.GetClient(234)); // no live notify connection anywhere in this test
|
||||
|
||||
var kick = PlayerUtils.BuildBlockDetails(true, ReportCategory.VoteKick, "kicked", DateTime.UtcNow.AddMinutes(15));
|
||||
Assert.True(await PlayerUtils.DeferForDeveloperAsync(db, 234, PlayerUtils.DeveloperDecisionKind.Kick, kick));
|
||||
|
||||
// The replacement is what the developer is asked about, so their answer applies the action
|
||||
// the moderator just issued instead of the one nobody could have read.
|
||||
var decision = PlayerUtils.TakeDeveloperDecision(234);
|
||||
Assert.NotNull(decision);
|
||||
Assert.Equal(PlayerUtils.DeveloperDecisionKind.Kick, decision!.Kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other half of the same rule: a developer who IS connected keeps the prompt they were
|
||||
/// already shown. Replacing a readable prompt would mean the developer reads one action and
|
||||
/// their answer applies a different one - the client answers with only the player's id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AConnectedDeveloperKeepsThePromptTheyWereAlreadyShown()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 235);
|
||||
|
||||
var client = new HubClient { AccountId = 235, ConnectionId = "warning-test-235" };
|
||||
Notify.Clients.Add(client);
|
||||
try
|
||||
{
|
||||
var warning = new PlayerWarning { WarnedPlayerId = 235, DisplayReason = "Trolling" };
|
||||
Assert.True(await PlayerUtils.DeferForDeveloperAsync(
|
||||
db, 235, PlayerUtils.DeveloperDecisionKind.Warning, PlayerUtils.BuildWarningBlockDetails(warning, 60)));
|
||||
|
||||
var kick = PlayerUtils.BuildBlockDetails(true, ReportCategory.VoteKick, "kicked", DateTime.UtcNow.AddMinutes(15));
|
||||
Assert.True(await PlayerUtils.DeferForDeveloperAsync(db, 235, PlayerUtils.DeveloperDecisionKind.Kick, kick));
|
||||
|
||||
Assert.Equal(PlayerUtils.DeveloperDecisionKind.Warning, PlayerUtils.TakeDeveloperDecision(235)!.Kind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Notify.Clients.Remove(client);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAnswerWithNothingParkedIsStable()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 234);
|
||||
|
||||
// A late answer (the decision expired), a duplicate, or a response that arrives after the
|
||||
// player reconnected: nothing is parked, so there is nothing to apply and nothing to crash.
|
||||
await AnswerVoteKickAsync(db, 234, "PlayerId=234&Response=true&GameSessionId=9304");
|
||||
|
||||
Assert.Empty(await db.PlayerWarnings.ToListAsync());
|
||||
Assert.Empty(await db.Moderation.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AVoteKickThatReachesADeveloperIsParkedUntilTheyAcceptIt()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
await WithDeveloperProfileAsync(db, 235);
|
||||
|
||||
const string vote = "PlayerId=235&Response=true&GameSessionId=9305&Reason=Griefing";
|
||||
foreach (var voter in new[] { 301L, 302L, 303L })
|
||||
await AnswerVoteKickAsync(db, voter, vote);
|
||||
|
||||
// The threshold was reached against a developer and nothing happened to them yet.
|
||||
Assert.Empty(await db.Moderation.ToListAsync());
|
||||
|
||||
await AnswerVoteKickAsync(db, 235, "PlayerId=235&Response=true&GameSessionId=9305");
|
||||
|
||||
// Accepted: the kick is applied for real, which is the delivery the park skipped.
|
||||
Assert.Single(await db.Moderation.ToListAsync());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// The write envelope
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task TheWriteEnvelopeAnswersBothKeyCasingsWithAnEmptyError()
|
||||
{
|
||||
using var db = await WithProfileAsync(6, "moderator");
|
||||
var api = NewApi(db, 6);
|
||||
SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded");
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(await api.CreateWarning());
|
||||
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
||||
|
||||
// This server answers its report write in PascalCase; the reference implementation
|
||||
// documents lowercase for this one. Newtonsoft ignores what it has no member for, so both
|
||||
// are served rather than betting on one.
|
||||
Assert.True(json.RootElement.GetProperty("Success").GetBoolean());
|
||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("", json.RootElement.GetProperty("Error").GetString());
|
||||
Assert.Equal("", json.RootElement.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------
|
||||
// Releasing the hold, and the reason the warn screen is filed under
|
||||
// -----------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The frame an acknowledgement pushes is the "nothing is pending against you" state, not a
|
||||
/// second warning. The client's dorm hold is a coroutine started by the duration on the kick
|
||||
/// frame, and the state it is handed on acknowledgement is what it renders: a cleared block
|
||||
/// must therefore raise neither the warn screen nor the ban screen - IsWarning and IsBan both
|
||||
/// false, Duration 0 - or acknowledging a warning would re-raise a moderation screen instead
|
||||
/// of closing one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheAcknowledgedStateRaisesNeitherScreen()
|
||||
{
|
||||
var cleared = PlayerUtils.BuildClearedBlockDetails();
|
||||
|
||||
Assert.False(cleared.IsWarning);
|
||||
Assert.False(cleared.IsBan);
|
||||
Assert.False(cleared.IsHostKick);
|
||||
Assert.Equal(0, cleared.Duration);
|
||||
Assert.Equal(string.Empty, cleared.Message);
|
||||
Assert.Equal(ReportCategory.Unknown, cleared.ReportCategory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sixth Code of Conduct reason has to be 200, because that is the number the 2023
|
||||
/// client's category enum carries for it (KOFEKCMPOEK.DHGJGCOMKOM.InappropriateClothing = 200)
|
||||
/// and the warn screen picks the reason it renders from that number. A different value would
|
||||
/// file the warning correctly on the server and show the player the wrong reason - and the
|
||||
/// admin panel's warn route validates on the same enum, so it has to be defined on both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheCustomClothingReasonIsTheNumberOfTheClientEnum()
|
||||
{
|
||||
Assert.Equal(200, (int)ReportCategory.InappropriateClothing);
|
||||
Assert.Equal(200, (int)KickReason.InappropriateClothing);
|
||||
|
||||
// What the panel's category validation (Enum.IsDefined) and the warn write both read.
|
||||
Assert.True(Enum.IsDefined(typeof(ReportCategory), 200));
|
||||
Assert.True(Enum.IsDefined(typeof(KickReason), 200));
|
||||
|
||||
// The five original reasons keep the numbers they already had: the panel and the client
|
||||
// both address them by number.
|
||||
Assert.Equal(100, (int)ReportCategory.CoC_Underage);
|
||||
Assert.Equal(101, (int)ReportCategory.CoC_Sexual);
|
||||
Assert.Equal(102, (int)ReportCategory.CoC_Discrimination);
|
||||
Assert.Equal(103, (int)ReportCategory.CoC_Trolling);
|
||||
Assert.Equal(104, (int)ReportCategory.CoC_NameOrProfile);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user