using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Moderation; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Responses; using Xunit; namespace RadicadeServer.Tests; /// /// Cheering another player (`api/PlayerCheer/v1/create`) and the cheer badges a profile reads /// back out of `api/playerReputation/v1/{id}`. /// /// Two things were wrong with the loop. A cheer only ever moved a column: the target was never /// sent a PlayerCheer message, so nothing appeared on their client at all. And the reputation /// payload hardcoded IsCheerful = true while flooring every category count at 1, so the client /// drew all five cheer badges as already earned - on accounts that had never been cheered. /// /// These tests pin the ledger end to end: the cheer is stored, the target is told, the count is /// reported as it really is, and the badge appears at Reputation.CheerBadgeThreshold cheers. /// [Collection(DbFactoryCollection.Name)] public class PlayerCheerTests { private const long CheererId = 41; private const long TargetId = 42; private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = null }; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() => new(options); } private static async Task<(RecDBContext Db, IDbContextFactory Factory, IDbContextFactory Previous)> CreateContextAsync() { var factory = new TestDbContextFactory(); var previous = Notify.DbFactory; Notify.DbFactory = factory; var db = factory.CreateDbContext(); db.Profiles.AddRange(ProfileFor(CheererId), ProfileFor(TargetId)); db.Players.AddRange(new PlayerData { AccountId = CheererId }, new PlayerData { AccountId = TargetId }); await db.SaveChangesAsync(); return (db, factory, previous); } private static Profile ProfileFor(long id) => new() { accountId = id, username = $"player{id}", displayName = $"Player {id}", profileImage = string.Empty, createdAt = DateTime.UtcNow }; private static void SetUser(Controller controller, long userId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static void SetFormBody(Controller controller, string body) { var request = controller.HttpContext.Request; request.ContentType = "application/x-www-form-urlencoded"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); request.ContentLength = request.Body.Length; } private static async Task> CheerAsync(ReputationAPI api, long to, int category, long from = CheererId) { SetUser(api, from); SetFormBody(api, $"PlayerIdTo={to}&CheerCategory={category}"); return await api.CheerPlayer(); } private static JsonElement ReputationJson(RecDBContext db, long accountId) { var result = Assert.IsType(new ReputationAPI(db).GetReputation(accountId)); using var doc = JsonDocument.Parse(JsonSerializer.Serialize(result.Value, ServerJson)); return doc.RootElement.Clone(); } [Fact] public async Task Cheer_ReachesTheTargetAsAPlayerCheerMessage() { var (db, factory, previous) = await CreateContextAsync(); await using var disposeDb = db; try { var result = await CheerAsync(new ReputationAPI(db), TargetId, (int)CheerCategory.Helpful); Assert.IsType(result.Result); await using var verify = factory.CreateDbContext(); var message = Assert.Single(verify.Messages); Assert.Equal(TargetId, message.ToPlayerId); Assert.Equal(CheererId, message.FromPlayerId); Assert.Equal(MessageType.PlayerCheer, message.Type); Assert.Equal(((int)CheerCategory.Helpful).ToString(), message.Data); } finally { Notify.DbFactory = previous; } } [Fact] public async Task Cheer_CountsOnlyTheCategoryThatWasCheered() { var (db, _, previous) = await CreateContextAsync(); await using var disposeDb = db; try { var api = new ReputationAPI(db); Assert.IsType((await CheerAsync(api, TargetId, (int)CheerCategory.Helpful)).Result); Assert.IsType((await CheerAsync(api, TargetId, (int)CheerCategory.Helpful)).Result); var json = ReputationJson(db, TargetId); // Reported as stored: a count of 2 stays 2, and the categories nobody cheered // stay at 0 instead of being floored up to a badge-looking 1. Assert.Equal(2, json.GetProperty("CheerHelpful").GetInt32()); Assert.Equal(0, json.GetProperty("CheerGeneral").GetInt32()); Assert.Equal(0, json.GetProperty("CheerCreative").GetInt32()); Assert.Equal(0, json.GetProperty("CheerGreatHost").GetInt32()); Assert.Equal(0, json.GetProperty("CheerSportsman").GetInt32()); } finally { Notify.DbFactory = previous; } } [Fact] public async Task Cheer_BadgeIsEarnedAtTheFifthCheer() { var (db, _, previous) = await CreateContextAsync(); await using var disposeDb = db; try { var api = new ReputationAPI(db); for (var i = 0; i < Reputation.CheerBadgeThreshold - 1; i++) Assert.IsType((await CheerAsync(api, TargetId, (int)CheerCategory.General)).Result); var before = await db.Reputations.AsNoTracking().FirstAsync(x => x.AccountId == TargetId); Assert.False(before.CheerBadgeEarned); Assert.IsType((await CheerAsync(api, TargetId, (int)CheerCategory.General)).Result); var after = await db.Reputations.AsNoTracking().FirstAsync(x => x.AccountId == TargetId); Assert.True(after.CheerBadgeEarned); Assert.Equal(Reputation.CheerBadgeThreshold, after.CheerGeneral); // The badge is what the stored flag tracks; the wire gate the client reads to // decide whether the target may hand cheers out stays open either way. Assert.True(ReputationJson(db, TargetId).GetProperty("IsCheerful").GetBoolean()); } finally { Notify.DbFactory = previous; } } [Fact] public void Reputation_ReportsTheGateOpenAndTheCountersExactlyAsStored() { // IsCheerful on the wire is the "may hand out cheers" gate, which the reference // server answers true for every account - it is not the earned badge. What has to // stay honest is the counters: a stale row must not carry a badge the player never // earned, and nobody has cheered this account so every counter reads 0. var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new RecDBContext(options); db.Reputations.Add(new Reputation { AccountId = TargetId, IsCheerful = true, CheerGeneral = 1, CheerCreative = 1 }); db.SaveChanges(); var json = ReputationJson(db, TargetId); Assert.True(json.GetProperty("IsCheerful").GetBoolean()); Assert.Equal(1, json.GetProperty("CheerGeneral").GetInt32()); Assert.Equal(0, json.GetProperty("CheerHelpful").GetInt32()); } [Fact] public async Task Cheer_RejectsYourselfAndUnknownCategories() { var (db, _, previous) = await CreateContextAsync(); await using var disposeDb = db; try { var api = new ReputationAPI(db); Assert.IsType( (await CheerAsync(api, CheererId, (int)CheerCategory.General)).Result); Assert.IsType( (await CheerAsync(api, TargetId, 999)).Result); Assert.Empty(db.Messages); Assert.Empty(db.Reputations); } finally { Notify.DbFactory = previous; } } [Fact] public async Task Cheer_CreditStaysADailyAllowanceAndNotABadge() { var (db, _, previous) = await CreateContextAsync(); await using var disposeDb = db; try { // CheerCredit is how many cheers the player may hand out. An account with no credit // recorded keeps the client's 20-cheer default, which is why it is still floored - // unlike the five badge counts, which are reported exactly as stored. Assert.Equal(20, ReputationJson(db, CheererId).GetProperty("CheerCredit").GetInt32()); } finally { Notify.DbFactory = previous; } } }