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));
}
}