mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-27 16:09:07 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user