using System.Reflection; using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; using Microsoft.Extensions.Logging.Abstractions; using RadicadeServer.Admin; using RadicadeServer.API.Compatibility; using RadicadeServer.DB; using RadicadeServer.Migrations; using RadicadeServer.RecNet.Rooms; using Xunit; namespace RadicadeServer.Tests; /// /// Room comments (POST roomcomments/comments/create/{roomId}). /// /// The route answered 200 and stored nothing - the client considers the comment posted, drops the /// draft, and the comment is gone on reload. Silent, and indistinguishable from the room page /// being broken. These cover the three things that make a stored comment real: the route the /// client actually builds, a row with the caller as its author, and a table that a migration /// creates. /// public class RoomCommentTests { private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static CompatibilityAPI NewApi(RecDBContext db, long? callerId = 205) { var identity = new ClaimsIdentity("TestAuth"); if (callerId != null) identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, callerId.Value.ToString())); return new CompatibilityAPI(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } } }; } private static void SetBody(ControllerBase controller, string body, string contentType) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = contentType; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); } private static async Task WithRoomAsync(long roomId = 42) { var db = NewDb(); db.Rooms.Add(new RoomData { RoomId = roomId, Name = "TestRoom", CreatorAccountId = 1, CreatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); return db; } // ----------------------------------------------------------------------------------- // The route, and who is allowed to post // ----------------------------------------------------------------------------------- [Fact] public void TheRouteIsTheClientsAbsolutePathAndRequiresAUser() { var method = typeof(CompatibilityAPI).GetMethod(nameof(CompatibilityAPI.CreateRoomComment))!; var template = Assert.Single(method.GetCustomAttributes()).Template; Assert.Equal("/roomcomments/comments/create/{roomId:long}", template); // Absolute: a relative template would be concatenated onto the controller's own prefix and // answer a path no client asks for, which is this project's most repeated failure mode. Assert.StartsWith("/", template); // A comment has to have an author, and the author comes from the token. Assert.NotEmpty(method.GetCustomAttributes()); } [Fact] public async Task AnAnonymousCommentIsRefusedRatherThanStoredUnderSomeone() { using var db = await WithRoomAsync(); var api = NewApi(db, callerId: null); SetBody(api, """{"Comment":"hi"}""", "application/json"); Assert.IsType(await api.CreateRoomComment(42)); Assert.Empty(db.RoomComments); } // ----------------------------------------------------------------------------------- // Storage // ----------------------------------------------------------------------------------- [Fact] public async Task AJSONCommentIsStoredWithTheCallerAsItsAuthor() { using var db = await WithRoomAsync(); var api = NewApi(db); SetBody(api, """{"Comment":"great room!","CommentType":"Feedback"}""", "application/json"); Assert.IsType(await api.CreateRoomComment(42)); var comment = await db.RoomComments.SingleAsync(); Assert.Equal(42, comment.RoomId); Assert.Equal(205, comment.CreatorAccountId); // the caller, never a body field Assert.Equal("great room!", comment.Comment); Assert.Equal("Feedback", comment.CommentType); Assert.NotEqual(default, comment.CreatedAt); } [Fact] public async Task TheBodySpellingsTheClientMightUseAllLandInTheSameColumn() { using var db = await WithRoomAsync(); var api = NewApi(db); // A single-field DTO serializes to a bare JSON string, which is not an object at all. SetBody(api, "\"bare string body\"", "application/json"); Assert.IsType(await api.CreateRoomComment(42)); Assert.Equal("bare string body", (await db.RoomComments.SingleAsync()).Comment); } [Fact] public async Task AFormPostIsStoredToo() { using var db = await WithRoomAsync(); var api = NewApi(db); SetBody(api, "Comment=form+comment&commentType=Report", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateRoomComment(42)); var comment = await db.RoomComments.SingleAsync(); Assert.Equal("form comment", comment.Comment); Assert.Equal("Report", comment.CommentType); } [Fact] public async Task ACommentSentInTheQueryStringIsStored() { using var db = await WithRoomAsync(); var api = NewApi(db); // The client's own call carries the comment as a query parameter and posts no body at all, // which is why a real comment came back as "rejected: empty body" - only the body was read. api.ControllerContext.HttpContext.Request.QueryString = new QueryString("?comment=query%20comment"); Assert.IsType(await api.CreateRoomComment(42)); Assert.Equal("query comment", (await db.RoomComments.SingleAsync()).Comment); } [Fact] public async Task AQueryStringCommentStillTakesItsTypeFromTheForm() { using var db = await WithRoomAsync(); var api = NewApi(db); // Both carriers at once: whichever one names the text, the type must not be lost. api.ControllerContext.HttpContext.Request.QueryString = new QueryString("?comment=mixed"); SetBody(api, "commentType=Report", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateRoomComment(42)); var comment = await db.RoomComments.SingleAsync(); Assert.Equal("mixed", comment.Comment); Assert.Equal("Report", comment.CommentType); } [Fact] public async Task AnEmptyCommentIsRefusedSoTheClientKeepsTheDraft() { using var db = await WithRoomAsync(); var api = NewApi(db); SetBody(api, """{"Comment":" "}""", "application/json"); // Non-2xx on purpose: the client keeps what the player typed instead of dropping it. Assert.IsType(await api.CreateRoomComment(42)); Assert.Empty(db.RoomComments); } [Fact] public async Task ACommentWithNoTypeFallsBackToTheClientsOwnDefault() { using var db = await WithRoomAsync(); var api = NewApi(db); SetBody(api, """{"Comment":"no type"}""", "application/json"); Assert.IsType(await api.CreateRoomComment(42)); Assert.Equal("Feedback", (await db.RoomComments.SingleAsync()).CommentType); } [Fact] public async Task ThePositionIsKeptWhenTheClientSendsOneAndNullWhenItDoesNot() { using var db = await WithRoomAsync(); var withPosition = NewApi(db); SetBody(withPosition, """{"Comment":"here","Position":{"x":1.5,"y":2,"z":-3}}""", "application/json"); Assert.IsType(await withPosition.CreateRoomComment(42)); var withoutPosition = NewApi(db); SetBody(withoutPosition, """{"Comment":"anywhere"}""", "application/json"); Assert.IsType(await withoutPosition.CreateRoomComment(42)); var rows = await db.RoomComments.OrderBy(c => c.Id).ToListAsync(); Assert.Equal(1.5f, rows[0].PositionX); Assert.Equal(2f, rows[0].PositionY); Assert.Equal(-3f, rows[0].PositionZ); Assert.Null(rows[1].PositionX); } // ----------------------------------------------------------------------------------- // The table itself // ----------------------------------------------------------------------------------- [Fact] public void TheCommentsTableIsInTheModelAndAMigrationCreatesIt() { using var db = NewDb(); Assert.NotNull(db.RoomComments); // A DbSet with no migration is a column the running database never gets: the code compiles, // the query works in memory, and every insert fails on the server. Migrate() runs Up(), so // running it here is the same statement the server will execute. // Up() is protected (EF calls it itself), so it is invoked the way the framework does. var up = typeof(AddRoomComments).GetMethod("Up", BindingFlags.NonPublic | BindingFlags.Instance)!; var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); up.Invoke(new AddRoomComments(), new object[] { builder }); var statements = builder.Operations .OfType() .Select(op => op.Sql) .ToArray(); Assert.NotEmpty(statements); Assert.Contains(statements, op => op.Contains("CREATE TABLE", StringComparison.Ordinal)); Assert.Contains(statements, op => op.Contains("[dbo].[RoomComments]", StringComparison.Ordinal)); // Guarded, like every other hand-written migration here: a second run over a database that // already has the table is a no-op instead of a startup crash. Assert.Contains(statements, op => op.Contains("OBJECT_ID", StringComparison.Ordinal)); } // ----------------------------------------------------------------------------------- // The admin panel side // ----------------------------------------------------------------------------------- // The panel's routes reject an unauthenticated caller - the branch's admin sign-in // guards every one of them with AdminUtils.IsAdminKey - so these tests act as a // signed-in operator by presenting the key the server has configured. private const string TestAdminKey = "test-admin-key"; private static async Task AsAdminAsync(Func> call) { var original = DBConfig.AdminApiKey; DBConfig.AdminApiKey = TestAdminKey; try { return await call(); } finally { DBConfig.AdminApiKey = original; } } [Fact] public async Task ThePanelListsWhatPlayersActuallyWrote() { using var db = await WithRoomAsync(); var api = NewApi(db); SetBody(api, """{"Comment":"panel should see this"}""", "application/json"); Assert.IsType(await api.CreateRoomComment(42)); var panel = new AdminPanelAPI(db); var ok = Assert.IsType(await AsAdminAsync(() => panel.List("room_comments", TestAdminKey, null, null, null, true))); var json = System.Text.Json.JsonSerializer.SerializeToElement(ok.Value); var row = Assert.Single(json.EnumerateArray()); Assert.Equal("panel should see this", row.GetProperty("comment").GetString()); Assert.Equal(42, row.GetProperty("room_id").GetInt64()); Assert.Equal(205, row.GetProperty("creator_account_id").GetInt64()); } [Fact] public async Task ThePanelCanDeleteAComment() { using var db = await WithRoomAsync(); var api = NewApi(db); SetBody(api, """{"Comment":"delete me"}""", "application/json"); Assert.IsType(await api.CreateRoomComment(42)); var id = (await db.RoomComments.SingleAsync()).Id; var panel = new AdminPanelAPI(db); Assert.IsType(await AsAdminAsync(() => panel.Delete("room_comments", TestAdminKey, null, id.ToString(), "id", id.ToString(), null, null, null))); Assert.Empty(db.RoomComments); } }