mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
306 lines
12 KiB
C#
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);
|
|
}
|
|
}
|