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

306 lines
12 KiB
C#

using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using RadicadeServer.API.Avatars;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Auth;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Notifications;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// A custom avatar item is worn, so writing one has to reach the client that is wearing it.
/// The routes only ever wrote the row: a shirt that was drawn, recolored, renamed or deleted
/// stayed as it was on screen until the player restarted, because nothing told the client
/// that the item had changed. These tests pin the missing half - each write pushes the
/// account update (the same notification cheers and the selected cheer badge use) to the
/// owner's own connection.
///
/// The design filename changes on every redraw, so the account update makes clients resolve
/// the item again and fetch the new image rather than a cached one. Whether the client
/// redraws the worn shirt from that frame is a client-side question this suite cannot
/// answer; what is pinned here is that the server sends it.
/// </summary>
[Collection(DbFactoryCollection.Name)]
public class CustomAvatarItemRealtimeTests
{
private const long OwnerId = 61;
private const string OwnerConnection = "custom-item-owner";
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
public RecDBContext CreateDbContext() => new(options);
}
private sealed class RecordingProxy : ISingleClientProxy
{
public List<(string Method, string Json)> Messages { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Messages.Add((method, Assert.IsType<string>(args[0])));
return Task.CompletedTask;
}
public Task<T> InvokeCoreAsync<T>(string method, object?[] args, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
private sealed class RecordingClients : IHubClients
{
public RecordingProxy Broadcast { get; } = new();
public Dictionary<string, RecordingProxy> Connections { get; } = new();
public RecordingProxy For(string connectionId)
{
if (!Connections.TryGetValue(connectionId, out var proxy))
Connections[connectionId] = proxy = new RecordingProxy();
return proxy;
}
public IClientProxy All => Broadcast;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Broadcast;
public IClientProxy Client(string connectionId) => For(connectionId);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Broadcast;
public IClientProxy Group(string groupName) => Broadcast;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Broadcast;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Broadcast;
public IClientProxy User(string userId) => Broadcast;
public IClientProxy Users(IReadOnlyList<string> userIds) => Broadcast;
}
private sealed class RecordingHub : IHubContext<NotifyHub>
{
private readonly RecordingClients clients = new();
public RecordingClients Recorder => clients;
public IHubClients Clients => clients;
public IGroupManager Groups => throw new NotSupportedException();
}
private sealed class Fixture : IAsyncDisposable
{
public Fixture(RecDBContext db, TestDbContextFactory factory, RecordingHub hub)
{
Db = db;
this.factory = factory;
this.hub = hub;
}
private readonly TestDbContextFactory factory;
private readonly RecordingHub hub;
private readonly IDbContextFactory<RecDBContext> previousFactory = Notify.DbFactory;
private readonly IHubContext<NotifyHub> previousContext = Notify.Context;
public RecDBContext Db { get; }
public RecordingProxy Owner => hub.Recorder.For(OwnerConnection);
public ValueTask DisposeAsync()
{
lock (Notify.ClientsLock)
Notify.Clients.RemoveAll(client => client.ConnectionId == OwnerConnection);
Notify.DbFactory = previousFactory;
Notify.Context = previousContext;
return Db.DisposeAsync();
}
}
/// <summary>
/// Opens a database holding the owner's profile and player row, registers one notification
/// connection for them, and points the notifier at both. <see cref="Notify.DbFactory"/> has to
/// be set even though the controller already has a context: the notifier opens its own to read
/// what it sends.
/// </summary>
private static async Task<Fixture> OpenAsync()
{
var factory = new TestDbContextFactory();
var hub = new RecordingHub();
var db = factory.CreateDbContext();
db.Profiles.Add(new Profile
{
accountId = OwnerId,
username = $"player{OwnerId}",
displayName = $"Player {OwnerId}",
profileImage = string.Empty,
createdAt = DateTime.UtcNow
});
db.Players.Add(new PlayerData { AccountId = OwnerId });
await db.SaveChangesAsync();
await db.DisposeAsync();
lock (Notify.ClientsLock)
Notify.Clients.Add(new HubClient { AccountId = OwnerId, ConnectionId = OwnerConnection });
Notify.DbFactory = factory;
Notify.Context = hub;
return new Fixture(factory.CreateDbContext(), factory, hub);
}
private static CustomAvatarItemDB ShirtFor(Guid itemId) => new()
{
CustomAvatarItemId = itemId,
CreatorAccountId = OwnerId,
Name = "Shirt",
Description = string.Empty,
BaseAvatarItemColor = "red",
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
private static void SetUser(Controller controller, long userId)
{
var identity = new ClaimsIdentity("TestAuth");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
}
private static void SetDesignForm(Controller controller, Guid itemId, string color, string fileName)
{
var request = controller.HttpContext.Request;
request.ContentType = "multipart/form-data; boundary=radicade-test";
request.Form = new FormCollection(
new Dictionary<string, StringValues>
{
["customAvatarItemId"] = itemId.ToString(),
["customAvatarItemColor"] = color
},
new FormFileCollection
{
new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("png-bytes")), 0, 9, "design", fileName)
{
Headers = new HeaderDictionary(),
ContentType = "image/png"
}
});
}
private static void SetJsonBody(Controller controller, string json)
{
var request = controller.HttpContext.Request;
request.ContentType = "application/json";
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
request.ContentLength = request.Body.Length;
request.EnableBuffering();
}
private static void AssertAccountUpdateReachedTheOwner(RecordingProxy owner)
{
Assert.Contains(owner.Messages, frame =>
frame.Method == "Notification" && frame.Json.Contains("\"Id\":\"AccountUpdate\""));
}
[Fact]
public async Task RedrawingAShirtPushesTheAccountUpdate()
{
await using var fixture = await OpenAsync();
var itemId = Guid.NewGuid();
fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId));
await fixture.Db.SaveChangesAsync();
var api = new CustomAvatarItemsAPI(fixture.Db);
SetUser(api, OwnerId);
SetDesignForm(api, itemId, "blue", "shirt.png");
Assert.IsType<OkObjectResult>(await api.SaveDesign());
var stored = await fixture.Db.CustomAvatarItems.AsNoTracking().FirstAsync(x => x.CustomAvatarItemId == itemId);
Assert.Equal("blue", stored.BaseAvatarItemColor);
Assert.EndsWith(".png", stored.DesignFileName);
AssertAccountUpdateReachedTheOwner(fixture.Owner);
}
[Fact]
public async Task RenamingAnItemPushesTheAccountUpdate()
{
await using var fixture = await OpenAsync();
var itemId = Guid.NewGuid();
fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId));
await fixture.Db.SaveChangesAsync();
var api = new CustomAvatarItemsAPI(fixture.Db);
SetUser(api, OwnerId);
SetJsonBody(api, """{"Name":"Racing Shirt","BaseAvatarItemColor":"green"}""");
Assert.IsType<OkObjectResult>(await api.Update(itemId));
var stored = await fixture.Db.CustomAvatarItems.AsNoTracking().FirstAsync(x => x.CustomAvatarItemId == itemId);
Assert.Equal("Racing Shirt", stored.Name);
Assert.Equal("green", stored.BaseAvatarItemColor);
AssertAccountUpdateReachedTheOwner(fixture.Owner);
}
[Fact]
public async Task CreatingAnItemPushesTheAccountUpdate()
{
await using var fixture = await OpenAsync();
var api = new CustomAvatarItemsAPI(fixture.Db);
SetUser(api, OwnerId);
SetJsonBody(api, """{"Name":"Fresh Shirt","Description":"","Price":0,"BaseAvatarItemId":1,"Accessibility":0}""");
Assert.IsType<OkObjectResult>(await api.Create());
var stored = await fixture.Db.CustomAvatarItems.AsNoTracking()
.FirstAsync(x => x.CreatorAccountId == OwnerId);
Assert.Equal("Fresh Shirt", stored.Name);
AssertAccountUpdateReachedTheOwner(fixture.Owner);
}
[Fact]
public async Task DeletingAnItemPushesTheAccountUpdate()
{
await using var fixture = await OpenAsync();
var itemId = Guid.NewGuid();
fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId));
await fixture.Db.SaveChangesAsync();
var api = new CustomAvatarItemsAPI(fixture.Db);
SetUser(api, OwnerId);
Assert.IsType<OkObjectResult>(await api.Delete(itemId));
Assert.Empty(fixture.Db.CustomAvatarItems.Where(x => x.CustomAvatarItemId == itemId));
AssertAccountUpdateReachedTheOwner(fixture.Owner);
}
[Fact]
public async Task AWriteFromAnotherAccountDoesNotReachTheOwner()
{
await using var fixture = await OpenAsync();
var itemId = Guid.NewGuid();
fixture.Db.CustomAvatarItems.Add(ShirtFor(itemId));
await fixture.Db.SaveChangesAsync();
var api = new CustomAvatarItemsAPI(fixture.Db);
SetUser(api, OwnerId + 1);
SetJsonBody(api, """{"Name":"Not Mine"}""");
var result = Assert.IsType<StatusCodeResult>(await api.Update(itemId));
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
Assert.Empty(fixture.Owner.Messages);
var stored = await fixture.Db.CustomAvatarItems.AsNoTracking().FirstAsync(x => x.CustomAvatarItemId == itemId);
Assert.Equal("Shirt", stored.Name);
}
}