Files
Radicade/ServerTests/RoomJoinGiftFromTests.cs
T
2026-10-16 23:33:13 -07:00

259 lines
9.9 KiB
C#

using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.Admin;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// Who a room's join box is from. The box a player receives on joining already travelled with
/// RecNet's <c>Gift.FromPlayerId</c> - it is the field the client reads to put a name on the box -
/// but a room's join gift left it at zero, so every join box arrived from nobody.
///
/// The room's Room Details modal now carries a FromPlayerId, which lands in
/// <c>Rooms.JoinGiftFromPlayerId</c> and from there on the box. The value crosses a JSON boundary
/// twice, and an account id is eighteen digits, so the panel and the route keep it as digits in a
/// string rather than as a number: a JSON number is read back as a double and its last digits are
/// rounded away, which would name a different account.
///
/// These tests drive the real admin routes and then read the box the way a client does -
/// GET api/avatar/v2/gifts - rather than inspecting the row the route answered with.
[Collection(DbFactoryCollection.Name)]
public class RoomJoinGiftFromTests
{
private const string Secret = "room-join-gift-from-key";
private const long RoomId = 247;
private const long Joiner = 114903603133502227;
private const long Sender = 733901112234567890;
private static RecDBContext NewDb() => new(
TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
/// <summary>A room that hands every player a box on join, which is what makes the sender matter.</summary>
private static async Task<RecDBContext> RoomWithJoinGiftAsync()
{
var db = NewDb();
db.Rooms.Add(new RoomData
{
RoomId = RoomId,
Name = "RecCenter",
CreatorAccountId = 1,
JoinGiftEnabled = true,
JoinGiftOncePerPlayer = true,
JoinGiftTitle = "Room Visit Gift",
JoinGiftMessage = "Thanks for visiting!",
JoinGiftCurrencyType = RadicadeServer.RecNet.Enums.Store.CurrencyType.RecCenterTokens,
JoinGiftCurrencyAmount = 25
});
await db.SaveChangesAsync();
return db;
}
/// <summary>Patches one room the way the panel does, and returns the answer's status.</summary>
private static async Task<IActionResult> SaveRoomAsync(RecDBContext db, string payloadJson)
{
var api = new AdminPanelAPI(db);
var originalKey = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = Secret;
try
{
var payload = JsonDocument.Parse(payloadJson).RootElement;
return await api.Update("rooms", RoomId, Secret, payload);
}
finally
{
DBConfig.AdminApiKey = originalKey;
}
}
/// <summary>The box as the recipient's client reads it: GET api/avatar/v2/gifts.</summary>
private static async Task<List<Gift>> ClientGiftsAsync(RecDBContext db, long playerId)
{
var api = new AvatarV2(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, playerId.ToString())], "test"))
}
}
};
var result = await api.GetGifts();
var ok = Assert.IsType<OkObjectResult>(result);
return Assert.IsAssignableFrom<IEnumerable<Gift>>(ok.Value).ToList();
}
/// <summary>The one box the joiner's client reads, with the server's own naming on the wire.</summary>
private static async Task<JsonElement> JoinerBoxOnTheWireAsync(RecDBContext db)
{
Assert.True(await RoomJoinGiftUtils.TryDeliverAsync(db, Joiner, RoomId));
var read = Assert.Single(await ClientGiftsAsync(db, Joiner));
return JsonDocument.Parse(JsonSerializer.Serialize(read)).RootElement;
}
[Fact]
public async Task TheSenderThePanelSavesIsTheSenderOnTheJoinBox()
{
var db = await RoomWithJoinGiftAsync();
var result = await SaveRoomAsync(db, $$"""
{
"join_gift_from_player_id": "{{Sender}}",
"join_gift_title": "Room Visit Gift"
}
""");
Assert.IsType<OkObjectResult>(result);
var stored = await db.Rooms.SingleAsync(x => x.RoomId == RoomId);
Assert.Equal(Sender, stored.JoinGiftFromPlayerId);
// And what the joiner's client reads off the box it was handed.
var wire = await JoinerBoxOnTheWireAsync(db);
Assert.Equal(Sender, wire.GetProperty("FromPlayerId").GetInt64());
}
[Fact]
public async Task ASeventeenDigitSenderIsNotRoundedOnItsWayThroughThePanel()
{
var db = await RoomWithJoinGiftAsync();
Assert.True(Sender > int.MaxValue);
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}" }
""");
// The panel reads the id back as digits in a string, so the modal shows the digits that
// were saved rather than a rounded double, and saving again cannot drift the account.
var api = new AdminPanelAPI(db);
var originalKey = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = Secret;
string? asPanelReadsIt;
try
{
var details = Assert.IsType<OkObjectResult>(await api.RoomPanelDetails(RoomId, Secret));
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(details.Value));
var field = doc.RootElement.GetProperty("room").GetProperty("join_gift_from_player_id");
Assert.Equal(JsonValueKind.String, field.ValueKind);
asPanelReadsIt = field.GetString();
}
finally
{
DBConfig.AdminApiKey = originalKey;
}
Assert.Equal(Sender.ToString(), asPanelReadsIt);
}
[Fact]
public async Task AChangedSenderReissuesTheBox()
{
var db = await RoomWithJoinGiftAsync();
var before = (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftVersion;
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}" }
""");
// A new version means a new gift drop id, so a player who already opened the old box is
// handed the one that now names a sender.
var after = await db.Rooms.SingleAsync(x => x.RoomId == RoomId);
Assert.True(after.JoinGiftVersion > before);
}
[Fact]
public async Task ASaveThatLeavesTheSenderAloneDoesNotReissueTheBox()
{
var db = await RoomWithJoinGiftAsync();
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}" }
""");
var afterFirstSave = (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftVersion;
// The same id again - the modal posts every field on every save.
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}", "join_gift_enabled": true, "join_gift_once_per_player": true }
""");
var again = await db.Rooms.SingleAsync(x => x.RoomId == RoomId);
Assert.Equal(afterFirstSave, again.JoinGiftVersion);
Assert.Equal(Sender, again.JoinGiftFromPlayerId);
}
[Fact]
public async Task AClearedSenderLeavesTheBoxWithNoSender()
{
var db = await RoomWithJoinGiftAsync();
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}" }
""");
// The blank field is what the modal sends for no sender, and it has to clear the room
// rather than leave the old account on the box.
Assert.IsType<OkObjectResult>(await SaveRoomAsync(db, """{ "join_gift_from_player_id": "" }"""));
Assert.Equal(0, (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftFromPlayerId);
var wire = await JoinerBoxOnTheWireAsync(db);
Assert.Equal(0, wire.GetProperty("FromPlayerId").GetInt64());
}
[Fact]
public async Task ARoomNoPanelFieldArrivedForKeepsItsSender()
{
var db = await RoomWithJoinGiftAsync();
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}" }
""");
// An older panel, an import or a partial save leaves the field out entirely; that is not
// a request to clear it.
Assert.IsType<OkObjectResult>(await SaveRoomAsync(db, """{ "min_level": 5 }"""));
Assert.Equal(Sender, (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftFromPlayerId);
}
[Fact]
public async Task ASenderThatIsNotAnAccountIdIsRefusedRatherThanKept()
{
var db = await RoomWithJoinGiftAsync();
await SaveRoomAsync(db, $$"""
{ "join_gift_from_player_id": "{{Sender}}" }
""");
// The panel refuses this before it posts; if it ever reaches the route anyway, the room
// must not be left believing the sender changed.
var result = await SaveRoomAsync(db, """{ "join_gift_from_player_id": "not-a-player" }""");
var bad = Assert.IsType<BadRequestObjectResult>(result);
Assert.Contains("join_gift_from_player_id", bad.Value?.ToString());
Assert.Equal(Sender, (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftFromPlayerId);
}
[Fact]
public async Task ARoomThatNamesNoSenderStillHandsOutItsBox()
{
var db = await RoomWithJoinGiftAsync();
// Nothing here sets a sender: the box is still delivered, it just names nobody, which is
// how every join box behaved before the room could name a sender.
var wire = await JoinerBoxOnTheWireAsync(db);
Assert.Equal(0, wire.GetProperty("FromPlayerId").GetInt64());
Assert.Equal(25, wire.GetProperty("Currency").GetInt32());
}
}