using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using RadicadeServer.API.Moderation;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Enums.Moderation;
using Xunit;
namespace RadicadeServer.Tests;
///
/// Vote-to-kick, specifically the developer case (POST api/PlayerReporting/v2/voteToKick).
///
/// Three votes used to kick anyone, developers included, with no way back: the moderation row that
/// blocks matchmaking was written the moment the threshold was reached, so a developer who never
/// agreed to the kick was banned anyway and the kick could not be undone by declining.
///
/// A developer's kick now STANDS DOWN until they accept it - nothing is written at the threshold,
/// the kick is parked, their client is handed the prompt, and their answer on the same route
/// (PlayerId = themselves) decides it: accept applies the kick, decline drops it and leaves the
/// database exactly as it was.
///
/// These pin the decision, which is the part that was wrong. The prompt itself is delivered by the
/// same KickOrPromptAsync/Notify path the ban flow already uses.
///
[Collection(DbFactoryCollection.Name)]
public class VoteKickDeveloperTests
{
private static RecDBContext NewDb() => new(
TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static PlayerReportingAPI NewApi(RecDBContext db, long callerId)
{
var identity = new ClaimsIdentity("TestAuth");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, callerId.ToString()));
return new PlayerReportingAPI(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
}
};
}
private static void SetJson(Controller controller, string json)
{
var request = controller.ControllerContext.HttpContext.Request;
request.ContentType = "application/json";
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
}
private static string VoteBody(long targetId, bool response, long sessionId, string reason = "Cheating") =>
$$"""{"PlayerId":{{targetId}},"Response":{{(response ? "true" : "false")}},"GameSessionId":{{sessionId}},"Reason":"{{reason}}"}""";
/// One vote, from a distinct voter (the vote count is per voter, not per call).
private static async Task VoteAsync(RecDBContext db, long voterId, string body)
{
var api = NewApi(db, voterId);
SetJson(api, body);
// The action returns ActionResult, so Ok(...) arrives as its Result; a vote is
// always a 200, including the one that decides a developer's parked kick.
var result = await api.VoteToKick();
Assert.IsType(result.Result);
}
private static async Task WithTargetAsync(long targetId, string? roles)
{
var db = NewDb();
db.Profiles.Add(new Profile
{
accountId = targetId,
role = roles,
username = $"player{targetId}",
displayName = $"Player {targetId}",
profileImage = DBConfig.DefaultProfileImage
});
await db.SaveChangesAsync();
return db;
}
// -----------------------------------------------------------------------------------
// The behaviour being kept: an ordinary player is kicked by the vote
// -----------------------------------------------------------------------------------
[Fact]
public async Task ANonDeveloperIsKickedAsSoonAsTheVotePasses()
{
using var db = await WithTargetAsync(205, roles: null);
const long session = 910001;
await VoteAsync(db, 1, VoteBody(205, true, session));
await VoteAsync(db, 2, VoteBody(205, true, session));
Assert.Empty(db.Moderation);
await VoteAsync(db, 3, VoteBody(205, true, session));
var moderation = await db.Moderation.SingleAsync();
Assert.True(moderation.IsBan);
Assert.Equal("Cheating", moderation.Message);
}
// -----------------------------------------------------------------------------------
// The behaviour that changed: a developer's kick waits for them
// -----------------------------------------------------------------------------------
[Fact]
public async Task ADevelopersVoteKickIsParkedInsteadOfApplied()
{
using var db = await WithTargetAsync(206, "developer");
const long session = 910002;
await VoteAsync(db, 1, VoteBody(206, true, session));
await VoteAsync(db, 2, VoteBody(206, true, session));
await VoteAsync(db, 3, VoteBody(206, true, session));
// Nothing written: the moderation row is what blocks matchmaking, so writing it here is
// exactly the "kicked a developer who never agreed to it" bug.
Assert.Empty(db.Moderation);
}
[Fact]
public async Task ADeveloperAcceptingTheParkedKickIsKickedWheneverTheyAnswer()
{
using var db = await WithTargetAsync(207, "developer");
const long session = 910003;
for (long voter = 1; voter <= 3; voter++)
await VoteAsync(db, voter, VoteBody(207, true, session, "Being AFK"));
Assert.Empty(db.Moderation);
// Their own answer on the same route: PlayerId is themselves.
await VoteAsync(db, 207, VoteBody(207, true, session, "Being AFK"));
var moderation = await db.Moderation.SingleAsync();
Assert.True(moderation.IsBan);
Assert.Equal("Being AFK", moderation.Message);
}
[Fact]
public async Task ADeveloperDecliningTheParkedKickLeavesNothingBehind()
{
using var db = await WithTargetAsync(208, "developer");
const long session = 910004;
for (long voter = 1; voter <= 3; voter++)
await VoteAsync(db, voter, VoteBody(208, true, session));
Assert.Empty(db.Moderation);
await VoteAsync(db, 208, VoteBody(208, false, session));
// "Stands down" has to mean the database is untouched, not that a row was written and then
// apologised for - the row is what stops them joining a room later.
Assert.Empty(db.Moderation);
// And the parked kick is gone, so a later answer cannot kick them out of nowhere.
await VoteAsync(db, 208, VoteBody(208, true, session));
Assert.Empty(db.Moderation);
}
[Fact]
public async Task ADevelopersOwnAnswerIsNotCountedAsAVote()
{
using var db = await WithTargetAsync(209, "developer");
const long session = 910005;
// Answering a prompt they were never sent must not add a vote towards their own kick.
await VoteAsync(db, 209, VoteBody(209, true, session));
await VoteAsync(db, 209, VoteBody(209, true, session));
await VoteAsync(db, 209, VoteBody(209, true, session));
Assert.Empty(db.Moderation);
}
}