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.Relationships.V1; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Relationships; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// Two routes the client calls that never reached a handler (a 404 and a 400), plus the /// time-in-room XP rules: public rooms only, 10 XP every 5 minutes, 60 per room, 150 per day. /// [Collection(DbFactoryCollection.Name)] public class ProgressionAndReportingRouteTests { private const long CallerId = 42; private const long OtherId = 77; private static RecDBContext CreateDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static T CreateController(RecDBContext db, long? userId = CallerId) where T : Controller { var identity = new ClaimsIdentity("TestAuth"); if (userId.HasValue) identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString())); var controller = typeof(T) == typeof(PlayerReportingAPI) ? (T)(object)new PlayerReportingAPI(db) : (T)Activator.CreateInstance(typeof(T), db)!; controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; return controller; } private static void SetJsonBody(Controller controller, string jsonBody) { var bytes = Encoding.UTF8.GetBytes(jsonBody); controller.HttpContext.Request.Body = new MemoryStream(bytes); controller.HttpContext.Request.ContentType = "application/json"; controller.HttpContext.Request.ContentLength = bytes.Length; } // ---- POST api/relationships/sendfriendintroductions -> 404 ------------------------------ [Fact] public void SendFriendIntroductionsIsRegisteredOnTheUnversionedPath() { var attributes = typeof(RelationshipsV1).GetMethod(nameof(RelationshipsV1.SendFriendIntroductions))! .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true) .Cast() .ToList(); Assert.Contains(attributes, a => a.HttpMethods.Contains("POST")); // The client asks for `api/relationships/sendfriendintroductions`, with no version // segment - that is the spelling that 404ed. Assert.Contains(attributes, a => (a.Template ?? "").EndsWith("api/relationships/sendfriendintroductions")); } [Fact] public async Task SendFriendIntroductionsAnswersTheSharedSuccessShape() { using var db = CreateDb(); var controller = CreateController(db); SetJsonBody(controller, JsonSerializer.Serialize(new { toPlayerIds = new[] { OtherId }, aboutPlayerId = CallerId })); var result = Assert.IsType(await controller.SendFriendIntroductions()); // The client's wrapper returns ONE object of the same success type the PlayerReporting // routes answer with; an array of relationships is a Malformed Response to it (which is // exactly what the caller logged before this route was corrected). var ok = Assert.IsType(result.Value); Assert.True(ok.Success); } [Fact] public async Task SendFriendIntroductionsReadsTheIdsFromTheQueryToo() { using var db = CreateDb(); var controller = CreateController(db); // The ids may arrive in the query rather than the body; the route must still answer its // success object instead of throwing on an empty body. controller.HttpContext.Request.QueryString = new QueryString($"?toPlayerIds={OtherId}&aboutPlayerId={CallerId}"); var result = Assert.IsType(await controller.SendFriendIntroductions()); Assert.True(Assert.IsType(result.Value).Success); } [Fact] public async Task SendFriendIntroductionsToleratesJunkIds() { using var db = CreateDb(); var controller = CreateController(db); // A caller, a non-numeric entry and a real target must not throw: the route answers its // success object whatever the body contains. SetJsonBody(controller, JsonSerializer.Serialize(new { toPlayerIds = new object[] { CallerId, "not-a-number", OtherId } })); var result = Assert.IsType(await controller.SendFriendIntroductions()); Assert.True(Assert.IsType(result.Value).Success); } // ---- POST api/PlayerReporting/v1/instantKick -> 400 ------------------------------------ private static async Task WithTargetAsync(long targetId) { var db = CreateDb(); db.Profiles.Add(new Profile { accountId = targetId, username = $"player{targetId}", displayName = $"Player {targetId}", profileImage = DBConfig.DefaultProfileImage }); await db.SaveChangesAsync(); return db; } /// Unwraps the OkResponse action result and asserts it is a 200. private static async Task AssertOkAsync(Task> call) { var result = await call; Assert.IsType(result.Result); } private static async Task AssertKickedAsync(RecDBContext db, long targetId) { var moderation = await db.Moderation.SingleAsync(x => x.PlayerId == targetId); Assert.True(moderation.IsBan); } [Fact] public async Task InstantKickAcceptsTheTargetFromTheBody() { using var db = await WithTargetAsync(OtherId); var controller = CreateController(db); SetJsonBody(controller, JsonSerializer.Serialize(new { PlayerId = OtherId, GameSessionId = 7, Reason = "Cheating" })); await AssertOkAsync(controller.InstantKick()); await AssertKickedAsync(db, OtherId); } [Fact] public async Task InstantKickAcceptsTheTargetFromTheQueryString() { using var db = await WithTargetAsync(OtherId); var controller = CreateController(db); controller.HttpContext.Request.QueryString = new QueryString($"?playerId={OtherId}"); // No body at all: the id is only in the query. Binding used to look at the body alone, // which made this a 400 ("Missing player id") for a request that named its target. await AssertOkAsync(controller.InstantKick()); await AssertKickedAsync(db, OtherId); } [Fact] public async Task InstantKickAcceptsABareIdBody() { using var db = await WithTargetAsync(OtherId); var controller = CreateController(db); SetJsonBody(controller, OtherId.ToString()); await AssertOkAsync(controller.InstantKick()); await AssertKickedAsync(db, OtherId); } [Fact] public async Task InstantKickFindsAPlayerNamedFieldUnderAnyName() { using var db = await WithTargetAsync(OtherId); var controller = CreateController(db); // The client's own DTO has changed its field name between builds; anything that names a // player still resolves. SetJsonBody(controller, JsonSerializer.Serialize(new { request = new { kickedPlayerId = OtherId } })); await AssertOkAsync(controller.InstantKick()); await AssertKickedAsync(db, OtherId); } [Fact] public async Task InstantKickStillRefusesARequestThatNamesNobody() { using var db = await WithTargetAsync(OtherId); var controller = CreateController(db); SetJsonBody(controller, """{"Reason":"no target here"}"""); var result = await controller.InstantKick(); Assert.IsType(result.Result); Assert.Empty(await db.Moderation.ToListAsync()); } // ---- room XP rules ---------------------------------------------------------------------- private const long PublicRoomId = 101; private const long OtherPublicRoomId = 102; private static async Task SeedProgressAsync(RecDBContext db, long playerId) { var progression = new ProgressionData { PlayerId = playerId }; db.Progression.Add(progression); await db.SaveChangesAsync(); return progression; } /// Moves the room's last payout back in time so the next call sees a full interval. private static async Task RewindRoomClockAsync(RecDBContext db, long playerId, TimeSpan by) { var progression = await db.Progression.SingleAsync(x => x.PlayerId == playerId); var json = progression.GetProgressionJson()!; json.RoomXpLastGrantAt = (json.RoomXpLastGrantAt ?? DateTime.UtcNow).Subtract(by); progression.Progress = json.ToJson(); await db.SaveChangesAsync(); } private static async Task AwardAsync(RecDBContext db, long playerId, long roomId, bool isPublic = true) => await RoomProgressionService.AwardRoomXpAsync(db, playerId, roomId, isPublic); [Fact] public async Task EnteringARoomPaysNothingUntilAnIntervalHasPassed() { using var db = CreateDb(); var progression = await SeedProgressAsync(db, CallerId); // Arrival anchors the session and starts the clock. Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId)); Assert.Equal(0, progression.XP); await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(10, await AwardAsync(db, CallerId, PublicRoomId)); var json = progression.GetProgressionJson()!; Assert.Equal(10, json.RoomXpEarned); Assert.Equal(10, json.XpEarnedToday); Assert.Equal(10, progression.XP); } [Fact] public async Task APrivateRoomNeverPays() { using var db = CreateDb(); var progression = await SeedProgressAsync(db, CallerId); Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId, isPublic: false)); await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId, isPublic: false)); Assert.Equal(0, progression.XP); } [Fact] public async Task OneRoomStopsPayingOnceItHasGivenSixtyXp() { using var db = CreateDb(); var progression = await SeedProgressAsync(db, CallerId); Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId)); for (var i = 0; i < 6; i++) { await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(10, await AwardAsync(db, CallerId, PublicRoomId)); } // 60 XP is the room's ceiling: further intervals in the same room pay nothing... await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(10)); Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId)); Assert.Equal(60, progression.XP); // ...but moving to another room starts a fresh session. Assert.Equal(0, await AwardAsync(db, CallerId, OtherPublicRoomId)); await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(10, await AwardAsync(db, CallerId, OtherPublicRoomId)); Assert.Equal(70, progression.XP); var json = progression.GetProgressionJson()!; Assert.Equal(OtherPublicRoomId, json.RoomXpRoomId); Assert.Equal(10, json.RoomXpEarned); } [Fact] public async Task TheDailyCeilingStopsPayoutForTwentyFourHours() { using var db = CreateDb(); var progression = await SeedProgressAsync(db, CallerId); // Start the player two full rooms away from the daily ceiling: 60 + 60 = 120, then the // third room can only pay the remaining 30 - three instalments of 10. Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId)); for (var i = 0; i < 6; i++) { await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); await AwardAsync(db, CallerId, PublicRoomId); } Assert.Equal(0, await AwardAsync(db, CallerId, OtherPublicRoomId)); for (var i = 0; i < 6; i++) { await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); await AwardAsync(db, CallerId, OtherPublicRoomId); } Assert.Equal(120, progression.XP); // A third room can pay only 30 more before the daily ceiling: 120 + 30 = 150. const long thirdRoom = 103; Assert.Equal(0, await AwardAsync(db, CallerId, thirdRoom)); for (var i = 0; i < 3; i++) { await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(10, await AwardAsync(db, CallerId, thirdRoom)); } // The ceiling lands on exactly 150, which is what starts the 24 hour wait. Assert.Equal(150, progression.XP); // The next interval would take the player past 150, so the ceiling holds at 150. await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(0, await AwardAsync(db, CallerId, thirdRoom)); Assert.Equal(150, progression.XP); var json = progression.GetProgressionJson()!; Assert.Equal(150, json.XpEarnedToday); Assert.NotNull(json.XpDailyCapReachedAt); } [Fact] public async Task ReachingTheDailyCeilingLocksPayoutForTheNextTwentyFourHours() { using var db = CreateDb(); var progression = await SeedProgressAsync(db, CallerId); // Put the player one instalment from the ceiling, mid-session. Only 5 XP is left under // it, so that instalment pays the remainder rather than a whole 10. var json = progression.GetProgressionJson()!; json.XpEarnedToday = 145; json.XpEarnedTodayDate = DateTime.UtcNow.Date; json.RoomXpRoomId = PublicRoomId; json.RoomXpEarned = 20; json.RoomXpLastGrantAt = DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(5)); progression.Progress = json.ToJson(); await db.SaveChangesAsync(); Assert.Equal(5, await AwardAsync(db, CallerId, PublicRoomId)); json = progression.GetProgressionJson()!; Assert.Equal(150, json.XpEarnedToday); Assert.NotNull(json.XpDailyCapReachedAt); // Within the 24 hour wait the room cannot pay, however long the player stays. await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(30)); Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId)); Assert.Equal(5, progression.XP); // Moving to another room does not help - the ceiling is per player, not per room. Assert.Equal(0, await AwardAsync(db, CallerId, OtherPublicRoomId)); await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(0, await AwardAsync(db, CallerId, OtherPublicRoomId)); // Once the 24 hours are up, earnings resume. json = progression.GetProgressionJson()!; json.XpEarnedTodayDate = DateTime.UtcNow.Date; json.XpDailyCapReachedAt = DateTime.UtcNow.Subtract(TimeSpan.FromHours(25)); json.RoomXpRoomId = OtherPublicRoomId; json.RoomXpEarned = 0; json.RoomXpLastGrantAt = DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(5)); progression.Progress = json.ToJson(); await db.SaveChangesAsync(); Assert.Equal(10, await AwardAsync(db, CallerId, OtherPublicRoomId)); Assert.Equal(15, progression.XP); } [Fact] public async Task LevellingUpRaisesTheStoredLevelWithoutGiftRows() { using var db = CreateDb(); var progression = await SeedProgressAsync(db, CallerId); // Six intervals of 10 fill the room to its 60 XP ceiling, which the shipped map puts at // level 4 (thresholds 10/20/30/50/70). There are no GiftItems rows in this database, and // the level must still move - a missing reward row cannot freeze progression. Assert.Equal(0, await AwardAsync(db, CallerId, PublicRoomId)); for (var i = 0; i < 6; i++) { await RewindRoomClockAsync(db, CallerId, TimeSpan.FromMinutes(5)); Assert.Equal(10, await AwardAsync(db, CallerId, PublicRoomId)); } Assert.Equal(60, progression.XP); Assert.Equal(4, progression.Level); } [Fact] public async Task TheLeveledUpPlayerIsPushedToTheClientWithoutRelogging() { using var db = CreateDb(); await SeedProgressAsync(db, CallerId); db.Players.Add(new PlayerData { AccountId = CallerId, Hashword = "x" }); db.Profiles.Add(new Profile { accountId = CallerId, username = "tester", displayName = "Tester", profileImage = DBConfig.DefaultProfileImage }); await db.SaveChangesAsync(); // The push is best effort and must not throw when there is no connected client - what it // must not do is leave the level change to be noticed at the next login. await RoomProgressionService.PushProgressionUpdateAsync(db, CallerId); } [Fact] public async Task RoomXpIsRejectedForAnUnknownPlayerAndRoom() { using var db = CreateDb(); Assert.Equal(0, await RoomProgressionService.AwardRoomXpAsync(db, 0, PublicRoomId, true)); Assert.Equal(0, await RoomProgressionService.AwardRoomXpAsync(db, CallerId, 0, true)); } }