mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Events;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Events;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using Xunit;
|
||||
|
||||
namespace RadicadeServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// An event's start and end are the wall clock its creator picked, and the client posts them
|
||||
/// with no zone on them. Treating that value as the instant itself made both this server and the
|
||||
/// client file a fresh event as already finished: the Upcoming filters compare event windows to
|
||||
/// UTC, and a zone-less timestamp on the wire is read as UTC by the client, so anything the
|
||||
/// player scheduled "in twenty minutes" came back six hours in the past and was hidden. These
|
||||
/// tests pin the conversion on the way in and the `Z` form on the way out.
|
||||
/// </summary>
|
||||
public class EventTimeTests
|
||||
{
|
||||
private const long CreatorId = 42;
|
||||
|
||||
private static RecDBContext CreateDb() => new(TestDbOptions.Create()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
|
||||
private static EventsAPI CreateController(RecDBContext db, string? jsonBody = null)
|
||||
{
|
||||
var identity = new ClaimsIdentity("TestAuth");
|
||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString()));
|
||||
|
||||
var controller = new EventsAPI(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(identity)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (jsonBody != null)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(jsonBody);
|
||||
controller.HttpContext.Request.Body = new MemoryStream(bytes);
|
||||
controller.HttpContext.Request.ContentType = "application/json";
|
||||
controller.HttpContext.Request.ContentLength = bytes.Length;
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static async Task<long> SeedRoomAsync(RecDBContext db)
|
||||
{
|
||||
var room = new RoomData { Name = "Event Room" };
|
||||
db.Rooms.Add(room);
|
||||
await db.SaveChangesAsync();
|
||||
return room.RoomId;
|
||||
}
|
||||
|
||||
private static async Task<long> CreateAsync(RecDBContext db, long roomId, string startTime, string endTime)
|
||||
{
|
||||
var body =
|
||||
$$"""
|
||||
{
|
||||
"Name": "Time check",
|
||||
"RoomId": {{roomId}},
|
||||
"StartTime": "{{startTime}}",
|
||||
"EndTime": "{{endTime}}"
|
||||
}
|
||||
""";
|
||||
var created = Assert.IsType<OkObjectResult>(await CreateController(db, body).CreateEventV2());
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(created.Value));
|
||||
return document.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64();
|
||||
}
|
||||
|
||||
/// <summary>An event time with no zone, the way the game posts it.</summary>
|
||||
private static string WallClock(DateTime local) => local.ToString("yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
/// <summary>The same instant with the sub-second ticks the wall-clock form drops.</summary>
|
||||
private static DateTime WholeSecond(DateTime value)
|
||||
=> new(value.Ticks - value.Ticks % TimeSpan.TicksPerSecond, value.Kind);
|
||||
|
||||
[Fact]
|
||||
public async Task APostedWallClockIsStoredAsTheUtcInstantItNames()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var roomId = await SeedRoomAsync(db);
|
||||
var picked = new DateTime(2026, 10, 8, 20, 10, 0, DateTimeKind.Unspecified);
|
||||
|
||||
await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddMinutes(30)));
|
||||
|
||||
var stored = await db.PlayerEvents.SingleAsync();
|
||||
var expected = TimeZoneInfo.ConvertTimeToUtc(picked, TimeZoneInfo.Local);
|
||||
Assert.Equal(expected, stored.StartTime);
|
||||
Assert.Equal(expected.AddMinutes(30), stored.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APostedUtcTimestampIsHonouredAsTheInstantItNames()
|
||||
{
|
||||
// A client that starts sending real UTC must not be shifted by the server's zone.
|
||||
var db = CreateDb();
|
||||
var roomId = await SeedRoomAsync(db);
|
||||
var instant = new DateTime(2026, 10, 9, 3, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
await CreateAsync(db, roomId, instant.ToString("o"), instant.AddMinutes(30).ToString("o"));
|
||||
|
||||
var stored = await db.PlayerEvents.SingleAsync();
|
||||
Assert.Equal(instant, stored.StartTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EventTimestampsGoOutAsUtcWithATrailingZ()
|
||||
{
|
||||
// Real RecNet serves every event time as `...Z`, and a zone-less value is read as UTC by
|
||||
// the client - so the read shape, the write envelope and the notification frame must all
|
||||
// carry the zone, or the client's own not-ended filter drops the event.
|
||||
var db = CreateDb();
|
||||
var roomId = await SeedRoomAsync(db);
|
||||
var picked = WholeSecond(DateTime.Now.AddMinutes(20));
|
||||
var eventId = await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddHours(1)));
|
||||
|
||||
var controller = CreateController(db);
|
||||
var read = await controller.GetEvent(eventId);
|
||||
var ok = Assert.IsType<OkObjectResult>(read.Result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
|
||||
var startTime = Assert.IsType<string>(document.RootElement.GetProperty("StartTime").GetString());
|
||||
var endTime = Assert.IsType<string>(document.RootElement.GetProperty("EndTime").GetString());
|
||||
|
||||
Assert.EndsWith("Z", startTime);
|
||||
Assert.EndsWith("Z", endTime);
|
||||
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(picked, TimeZoneInfo.Local),
|
||||
DateTime.Parse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnEventScheduledInTwentyMinutesIsUpcomingEverywhereTheClientLooks()
|
||||
{
|
||||
// The bug this guards: an event made "in twenty minutes" was stored as the wall clock and
|
||||
// compared to UTC now, so it was six hours finished as far as every Upcoming-shaped query
|
||||
// - and the client's own ended filter - were concerned.
|
||||
var db = CreateDb();
|
||||
var roomId = await SeedRoomAsync(db);
|
||||
var picked = DateTime.Now.AddMinutes(20);
|
||||
var eventId = await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddHours(1)));
|
||||
var controller = CreateController(db);
|
||||
|
||||
Assert.Contains(await controller.GetBrowseFeed(0, 50), e => e.PlayerEventId == eventId);
|
||||
Assert.Contains(await controller.GetUpcomingEvents(), e => e.PlayerEventId == eventId);
|
||||
Assert.Contains(await controller.SearchEvents(null, null), e => e.PlayerEventId == eventId);
|
||||
|
||||
// What the client itself checks: the timestamp parsed as UTC is still in the future.
|
||||
var later = await controller.GetBrowseFeed(0, 50);
|
||||
var onWire = later.Single(e => e.PlayerEventId == eventId).StartTime;
|
||||
Assert.True(DateTimeOffset.Parse(EventClock.ToWire(onWire)) > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnEventThatHasFinishedIsStillKeptOutOfTheUpcomingFeeds()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var roomId = await SeedRoomAsync(db);
|
||||
var picked = DateTime.Now.AddHours(-4);
|
||||
var eventId = await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddHours(2)));
|
||||
var controller = CreateController(db);
|
||||
|
||||
Assert.DoesNotContain(await controller.GetBrowseFeed(0, 50), e => e.PlayerEventId == eventId);
|
||||
Assert.DoesNotContain(await controller.GetUpcomingEvents(), e => e.PlayerEventId == eventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheClockConvertsAWallClockAndPassesAUtcInstantThrough()
|
||||
{
|
||||
var wallClock = new DateTime(2026, 10, 8, 20, 10, 0, DateTimeKind.Unspecified);
|
||||
|
||||
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(wallClock, TimeZoneInfo.Local),
|
||||
EventClock.FromPostedTime(wallClock));
|
||||
|
||||
var utc = new DateTime(2026, 10, 9, 3, 10, 0, DateTimeKind.Utc);
|
||||
Assert.Equal(utc, EventClock.FromPostedTime(utc));
|
||||
Assert.Equal("2026-10-09T03:10:00.0000000Z", EventClock.ToWire(utc));
|
||||
|
||||
// A value read back from the store carries no kind; it is already UTC and must not be
|
||||
// converted a second time.
|
||||
Assert.Equal("2026-10-09T03:10:00.0000000Z",
|
||||
EventClock.ToWire(new DateTime(2026, 10, 9, 3, 10, 0, DateTimeKind.Unspecified)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user