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; /// /// 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. /// 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 SeedRoomAsync(RecDBContext db) { var room = new RoomData { Name = "Event Room" }; db.Rooms.Add(room); await db.SaveChangesAsync(); return room.RoomId; } private static async Task CreateAsync(RecDBContext db, long roomId, string startTime, string endTime) { var body = $$""" { "Name": "Time check", "RoomId": {{roomId}}, "StartTime": "{{startTime}}", "EndTime": "{{endTime}}" } """; var created = Assert.IsType(await CreateController(db, body).CreateEventV2()); using var document = JsonDocument.Parse(JsonSerializer.Serialize(created.Value)); return document.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64(); } /// An event time with no zone, the way the game posts it. private static string WallClock(DateTime local) => local.ToString("yyyy-MM-dd'T'HH:mm:ss"); /// The same instant with the sub-second ticks the wall-clock form drops. 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(read.Result); using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); var startTime = Assert.IsType(document.RootElement.GetProperty("StartTime").GetString()); var endTime = Assert.IsType(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))); } }