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; /// /// 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 <3" when they left it empty, and a player may only send once a day. /// public class FriendotronTests { private const long SenderId = 61; private const long FirstFriendId = 62; private const long SecondFriendId = 63; /// /// 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. /// 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; } /// The client's request DTO: the recipients, the lid message, and the gift context. private static string SendBody(IEnumerable 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 recipients, string? message = null) { SetUser(api, senderId); SetJsonBody(api, SendBody(recipients, message)); var result = Assert.IsType(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 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(); 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()); Assert.False(success); Assert.Contains("recipients", error); } [Fact] public void ARolledBoxIsAlwaysAOneTwoOrThreeStarConsumable() { LoadShippedCatalog(); var tokens = ShippedConsumableTokens(); var random = new Random(20261012); var tiers = new HashSet(); 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 ")); } }