mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
436 lines
18 KiB
C#
436 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<T>(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<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
|
|
.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<RelationshipsV1>(db);
|
|
SetJsonBody(controller, JsonSerializer.Serialize(new { toPlayerIds = new[] { OtherId }, aboutPlayerId = CallerId }));
|
|
|
|
var result = Assert.IsType<OkObjectResult>(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<OkResponse>(result.Value);
|
|
Assert.True(ok.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendFriendIntroductionsReadsTheIdsFromTheQueryToo()
|
|
{
|
|
using var db = CreateDb();
|
|
var controller = CreateController<RelationshipsV1>(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<OkObjectResult>(await controller.SendFriendIntroductions());
|
|
Assert.True(Assert.IsType<OkResponse>(result.Value).Success);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendFriendIntroductionsToleratesJunkIds()
|
|
{
|
|
using var db = CreateDb();
|
|
var controller = CreateController<RelationshipsV1>(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<OkObjectResult>(await controller.SendFriendIntroductions());
|
|
Assert.True(Assert.IsType<OkResponse>(result.Value).Success);
|
|
}
|
|
|
|
// ---- POST api/PlayerReporting/v1/instantKick -> 400 ------------------------------------
|
|
|
|
private static async Task<RecDBContext> 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;
|
|
}
|
|
|
|
/// <summary>Unwraps the OkResponse action result and asserts it is a 200.</summary>
|
|
private static async Task AssertOkAsync(Task<ActionResult<OkResponse>> call)
|
|
{
|
|
var result = await call;
|
|
Assert.IsType<OkObjectResult>(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<PlayerReportingAPI>(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<PlayerReportingAPI>(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<PlayerReportingAPI>(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<PlayerReportingAPI>(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<PlayerReportingAPI>(db);
|
|
SetJsonBody(controller, """{"Reason":"no target here"}""");
|
|
|
|
var result = await controller.InstantKick();
|
|
Assert.IsType<BadRequestObjectResult>(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<ProgressionData> SeedProgressAsync(RecDBContext db, long playerId)
|
|
{
|
|
var progression = new ProgressionData { PlayerId = playerId };
|
|
db.Progression.Add(progression);
|
|
await db.SaveChangesAsync();
|
|
return progression;
|
|
}
|
|
|
|
/// <summary>Moves the room's last payout back in time so the next call sees a full interval.</summary>
|
|
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<int> 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));
|
|
}
|
|
}
|