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

314 lines
13 KiB
C#

using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Compatibility;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// The Friendotron: the free gift-for-friends button in the Rec Center
/// (POST /econ/api/freegifts/v1/sendmultiple).
///
/// The route used to answer a success flag and nothing else - no box, no item - so a send looked
/// like it worked and no friend ever received anything. A send now hands every recipient a gift
/// box holding a random one to three star consumable, labelled with the sender's message or
/// "A gift for you &lt;3" when they left it empty, and a player may only send once a day.
/// </summary>
public class FriendotronTests
{
private const long SenderId = 61;
private const long FirstFriendId = 62;
private const long SecondFriendId = 63;
/// <summary>
/// The boxes are rolled from the catalogue the server ships, so these tests read the same
/// files it does - once per run rather than once per test, because LoadFiles rewrites the
/// process-wide catalogue every other catalog test in this suite reads.
/// </summary>
private static readonly object CatalogLock = new();
private static bool catalogLoaded;
private static void LoadShippedCatalog()
{
lock (CatalogLock)
{
if (catalogLoaded) return;
DBConfig.LoadFiles();
catalogLoaded = true;
}
}
private static async Task<(SqliteConnection Connection, RecDBContext Db)> OpenDbAsync(params long[] accountIds)
{
var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = TestDbOptions.Create().UseSqlite(connection).Options;
var db = new RecDBContext(options);
await db.Database.EnsureCreatedAsync();
foreach (var accountId in accountIds)
db.Players.Add(new PlayerData { AccountId = accountId, Hashword = "x" });
await db.SaveChangesAsync();
return (connection, db);
}
private static void SetUser(ControllerBase controller, long accountId)
{
var identity = new ClaimsIdentity("TestAuth");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString()));
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
private static void SetJsonBody(ControllerBase controller, string body)
{
var request = controller.HttpContext.Request;
request.ContentType = "application/json";
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
request.ContentLength = request.Body.Length;
}
/// <summary>The client's request DTO: the recipients, the lid message, and the gift context.</summary>
private static string SendBody(IEnumerable<long> recipients, string? message) =>
new JsonObject
{
["ToPlayerIds"] = new JsonArray(recipients.Select(id => (JsonNode)JsonValue.Create(id)!).ToArray()),
["Message"] = message ?? string.Empty,
["GiftContext"] = (int)GiftContext.Friendotron_Gift
}.ToJsonString();
private static async Task<(bool Success, string Error)> SendAsync(
CompatibilityAPI api, long senderId, IEnumerable<long> recipients, string? message = null)
{
SetUser(api, senderId);
SetJsonBody(api, SendBody(recipients, message));
var result = Assert.IsType<OkObjectResult>(await api.SendFreeGifts());
using var document = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
return (
document.RootElement.GetProperty("ALOCDGMHFKA").GetBoolean(),
document.RootElement.GetProperty("FBILGKFKFJE").GetString() ?? string.Empty);
}
private static HashSet<string> ShippedConsumableTokens() =>
ConsumableCatalog.ParseItems(DBConfig.DefaultConsumableItems)
.Select(item => ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
[Fact]
public async Task ASendPutsABoxInEveryRecipientsHands()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId, FirstFriendId, SecondFriendId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var tokens = ShippedConsumableTokens();
Assert.NotEmpty(tokens);
var (success, error) = await SendAsync(new CompatibilityAPI(db), SenderId, new[] { FirstFriendId, SecondFriendId });
Assert.True(success, error);
Assert.Equal(string.Empty, error);
foreach (var recipient in new[] { FirstFriendId, SecondFriendId })
{
var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, recipient));
Assert.Equal(SenderId, box.FromPlayerId);
Assert.Equal(GiftContext.Friendotron_Gift, box.Context);
// One to three stars on the box, and a real catalogue consumable inside it.
Assert.Contains(box.Rarity, FriendotronService.StarTiers);
Assert.Contains(box.ConsumableItemDesc, tokens);
Assert.Equal("A gift for you <3", box.Tooltip);
}
// Nobody else was sent anything.
Assert.Empty(await StorefrontShared.GetPendingGiftsAsync(db, SenderId));
}
[Fact]
public async Task OpeningTheBoxPutsTheConsumableInTheRecipientsInventory()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId, FirstFriendId);
await using var disposeConnection = connection;
await using var disposeDb = db;
Assert.True((await SendAsync(new CompatibilityAPI(db), SenderId, new[] { FirstFriendId })).Success);
var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, FirstFriendId));
// The claim route's own sequence: grant the gift, then retire the pending box.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
await StorefrontShared.GrantGiftAsync(db, FirstFriendId, box);
await StorefrontShared.RemovePendingGiftAsync(db, FirstFriendId, box.GiftDropId);
await tx.CommitAsync();
});
var unlocks = await db.Unlocks.AsNoTracking().FirstAsync(x => x.AccountId == FirstFriendId);
var owned = unlocks.GetConsumables() ?? new List<Consumable>();
Assert.Contains(owned, item => item.ConsumableItemDesc == box.ConsumableItemDesc);
Assert.Empty(await StorefrontShared.GetPendingGiftsAsync(db, FirstFriendId));
}
[Fact]
public async Task TheSendersMessageRidesAlongOnTheBox()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId, FirstFriendId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var (success, error) = await SendAsync(new CompatibilityAPI(db), SenderId, new[] { FirstFriendId }, "Monkey-Jelly");
Assert.True(success, error);
var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, FirstFriendId));
Assert.Equal("Monkey-Jelly", box.Tooltip);
}
[Fact]
public async Task ASecondSendWithinADayIsRefused()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId, FirstFriendId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var api = new CompatibilityAPI(db);
Assert.True((await SendAsync(api, SenderId, new[] { FirstFriendId })).Success);
var (success, error) = await SendAsync(api, SenderId, new[] { FirstFriendId });
Assert.False(success);
Assert.Contains("another friend box", error);
// The refusal did not slip a second box in behind it.
Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, FirstFriendId));
}
[Fact]
public async Task ASendIsAllowedAgainOnceTheDayIsUp()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId, FirstFriendId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var api = new CompatibilityAPI(db);
Assert.True((await SendAsync(api, SenderId, new[] { FirstFriendId })).Success);
// Stand the stamp back a day and a bit: the cooldown is 24 hours, not a session or a lock.
var key = FriendotronService.CooldownKey(SenderId);
var stamp = await db.ServerConfig.FirstAsync(x => x.Key == key);
stamp.Value = DateTime.UtcNow.AddHours(-25).ToString("O");
await db.SaveChangesAsync();
Assert.True((await SendAsync(api, SenderId, new[] { FirstFriendId })).Success);
Assert.Equal(2, (await StorefrontShared.GetPendingGiftsAsync(db, FirstFriendId)).Count);
}
[Fact]
public async Task SelfGiftingFollowsTheConfigFlag()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var api = new CompatibilityAPI(db);
// The seeded config allows self gifting, so a box to yourself is a box.
Assert.True((await SendAsync(api, SenderId, new[] { SenderId })).Success);
Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, SenderId));
db.GameConfig.Add(new ConfigVal("Friendotron_AllowSelfGifting", "false"));
var stamp = await db.ServerConfig.FirstAsync(x => x.Key == FriendotronService.CooldownKey(SenderId));
stamp.Value = DateTime.UtcNow.AddHours(-25).ToString("O");
await db.SaveChangesAsync();
var (success, error) = await SendAsync(api, SenderId, new[] { SenderId });
Assert.False(success);
Assert.Contains("yourself", error);
Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, SenderId));
}
[Fact]
public async Task AnUnknownRecipientIsRefusedAndNothingIsSent()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId, FirstFriendId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var (success, error) = await SendAsync(new CompatibilityAPI(db), SenderId, new[] { FirstFriendId, 999 });
Assert.False(success);
Assert.Contains("999", error);
// All or nothing: the friend who does exist gets no box from a send that was refused.
Assert.Empty(await StorefrontShared.GetPendingGiftsAsync(db, FirstFriendId));
Assert.Empty(db.ServerConfig);
}
[Fact]
public async Task ASendWithNoRecipientsIsRefused()
{
LoadShippedCatalog();
var (connection, db) = await OpenDbAsync(SenderId);
await using var disposeConnection = connection;
await using var disposeDb = db;
var (success, error) = await SendAsync(new CompatibilityAPI(db), SenderId, Array.Empty<long>());
Assert.False(success);
Assert.Contains("recipients", error);
}
[Fact]
public void ARolledBoxIsAlwaysAOneTwoOrThreeStarConsumable()
{
LoadShippedCatalog();
var tokens = ShippedConsumableTokens();
var random = new Random(20261012);
var tiers = new HashSet<Rarity>();
for (var roll = 0; roll < 200; roll++)
{
var item = FriendotronService.RollConsumable(DBConfig.DefaultConsumableItems, out var starTier, random);
Assert.NotNull(item);
Assert.Contains(starTier, FriendotronService.StarTiers);
Assert.Contains(ConsumableCatalog.ToClientDesc(item!.ConsumableItemDesc), tokens);
tiers.Add(starTier);
}
// All three star levels are reachable, not just the tier every shipped consumable sits in.
Assert.Equal(FriendotronService.StarTiers.OrderBy(x => x).ToArray(), tiers.OrderBy(x => x).ToArray());
}
[Fact]
public void TheDefaultLidMessageIsUsedOnlyWhenTheSenderTypedNothing()
{
Assert.Equal("A gift for you <3", FriendotronService.MessageOr(null));
Assert.Equal("A gift for you <3", FriendotronService.MessageOr(" "));
Assert.Equal("Monkey-Jelly", FriendotronService.MessageOr(" Monkey-Jelly "));
}
}